Files
zeekayandHanzo Dev c99f2a73c1 bench: LoCoMo, over a graph and over a heap of turns
The recall benchmark run twice from these packages over the same 5,882 turns,
with the same reader and the dataset's own scorer: turns in a store.Mem
retrieved by cosine, and a kg graph of who did what retrieved by looking up the
person a question names. An oracle arm holding the annotated evidence bounds
what the reader can do at all.

The graph scores 0.223 against the flat index's 0.156 overall — level on the
1,540 answerable questions, 0.502 against 0.200 on the 446 adversarial ones —
and leads at every matched rate of declining to answer. It gets there by being
selectively blind: it reaches 58-79% of the evidence for questions that have an
answer and 27% for questions that do not, because a turn about somebody else is
not a candidate however close its wording. Where it loses is reported too.

The metric is ported from task_eval/evaluation.py, NLTK's Porter stemmer
included, and held against the Python by three golden tests and once end to
end: score.py runs the official code over the same predictions and reproduces
every figure to three decimals.

LoCoMo is CC BY-NC and this module is MIT, so neither the dataset nor their
scorer is kept here. Both are fetched on first use, the dataset through
ingest.Web.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-09 00:15:15 -07:00

329 lines
7.9 KiB
Go

package main
import (
"strings"
"sync"
)
// Porter stemming, in the variant NLTK ships as its default. LoCoMo scores an
// answer by token overlap after stemming, so "allergies" and "allergy" have to
// count as one word or a correct answer loses points for its inflection. NLTK
// is what the official scorer calls, and NLTK's stemmer is not the published
// 1980 algorithm: it carries a table of irregular forms, a different y-to-i
// condition, and its own reading of -ies and -ied. Reproducing those here is
// what makes the Go numbers and the Python numbers the same numbers;
// testdata/stems.tsv holds NLTK's answer for every word in the corpus and the
// test asserts this agrees with all 6,597 of them.
// stem reduces a lowercase word to its stem. The answer is kept, because a
// benchmark stems the same few thousand words several million times.
func stem(w string) string {
known.RLock()
s, seen := known.m[w]
known.RUnlock()
if seen {
return s
}
s = reduce(w)
known.Lock()
known.m[w] = s
known.Unlock()
return s
}
var known = struct {
sync.RWMutex
m map[string]string
}{m: map[string]string{}}
func reduce(w string) string {
if s, odd := irregular[w]; odd {
return s
}
if len(w) <= 2 {
return w
}
w = step1a(w)
w = step1b(w)
w = step1c(w)
w = step2(w)
w = step3(w)
w = step4(w)
w = step5a(w)
return step5b(w)
}
// rule removes a suffix and puts another in its place, when the condition
// holds of what is left. A nil condition always holds. The suffix "*d" stands
// for a doubled final consonant rather than for letters of its own.
type rule struct {
suffix string
with string
ok func(stem string) bool
}
// doubled is the suffix that means "ends in a doubled consonant".
const doubled = "*d"
// apply runs the first rule that fires and stops, whether or not its condition
// held: a suffix that matched is the answer to this step even when its
// condition refuses the change.
func apply(w string, rules []rule) string {
for _, r := range rules {
var s string
switch {
case r.suffix == doubled:
if !endsDouble(w) {
continue
}
s = w[:len(w)-2]
case strings.HasSuffix(w, r.suffix):
s = w[:len(w)-len(r.suffix)]
default:
continue
}
if r.ok == nil || r.ok(s) {
return s + r.with
}
return w
}
return w
}
// step1a takes the plural off.
func step1a(w string) string {
if strings.HasSuffix(w, "ies") && len(w) == 4 {
return w[:1] + "ie"
}
return apply(w, []rule{
{"sses", "ss", nil},
{"ies", "i", nil},
{"ss", "ss", nil},
{"s", "", nil},
})
}
// step1b takes -eed, -ed and -ing off, and repairs the stem left behind.
func step1b(w string) string {
if strings.HasSuffix(w, "ied") {
if len(w) == 4 {
return w[:1] + "ie"
}
return w[:len(w)-3] + "i"
}
if strings.HasSuffix(w, "eed") {
if s := w[:len(w)-3]; measure(s) > 0 {
return s + "ee"
}
return w
}
var mid string
var cut bool
for _, suffix := range [2]string{"ed", "ing"} {
if !strings.HasSuffix(w, suffix) {
continue
}
if s := w[:len(w)-len(suffix)]; hasVowel(s) {
mid, cut = s, true
break
}
}
if !cut {
return w
}
last := ""
if mid != "" {
last = mid[len(mid)-1:]
}
return apply(mid, []rule{
{"at", "ate", nil},
{"bl", "ble", nil},
{"iz", "ize", nil},
{doubled, last, func(string) bool { return last != "l" && last != "s" && last != "z" }},
{"", "e", func(s string) bool { return measure(s) == 1 && endsCVC(s) }},
})
}
// step1c turns a final y into i after a consonant.
func step1c(w string) string {
return apply(w, []rule{
{"y", "i", func(s string) bool { return len(s) > 1 && consonant(s, len(s)-1) }},
})
}
// step2 folds the long derivational endings onto shorter ones.
func step2(w string) string {
if strings.HasSuffix(w, "alli") && measure(w[:len(w)-4]) > 0 {
return step2(w[:len(w)-4] + "al")
}
return apply(w, []rule{
{"ational", "ate", grown},
{"tional", "tion", grown},
{"enci", "ence", grown},
{"anci", "ance", grown},
{"izer", "ize", grown},
{"bli", "ble", grown},
{"alli", "al", grown},
{"entli", "ent", grown},
{"eli", "e", grown},
{"ousli", "ous", grown},
{"ization", "ize", grown},
{"ation", "ate", grown},
{"ator", "ate", grown},
{"alism", "al", grown},
{"iveness", "ive", grown},
{"fulness", "ful", grown},
{"ousness", "ous", grown},
{"aliti", "al", grown},
{"iviti", "ive", grown},
{"biliti", "ble", grown},
{"fulli", "ful", grown},
// NLTK keeps the l of -logi with the stem, so that a short stem such
// as "geo" or "theo" still measures as grown.
{"logi", "log", func(string) bool { return measure(w[:len(w)-3]) > 0 }},
})
}
// step3 folds the remaining derivational endings away.
func step3(w string) string {
return apply(w, []rule{
{"icate", "ic", grown},
{"ative", "", grown},
{"alize", "al", grown},
{"iciti", "ic", grown},
{"ical", "ic", grown},
{"ful", "", grown},
{"ness", "", grown},
})
}
// step4 strips the endings a twice-grown stem can lose outright.
func step4(w string) string {
return apply(w, []rule{
{"al", "", twice},
{"ance", "", twice},
{"ence", "", twice},
{"er", "", twice},
{"ic", "", twice},
{"able", "", twice},
{"ible", "", twice},
{"ant", "", twice},
{"ement", "", twice},
{"ment", "", twice},
{"ent", "", twice},
{"ion", "", func(s string) bool {
return measure(s) > 1 && s != "" && (s[len(s)-1] == 's' || s[len(s)-1] == 't')
}},
{"ou", "", twice},
{"ism", "", twice},
{"ate", "", twice},
{"iti", "", twice},
{"ous", "", twice},
{"ive", "", twice},
{"ize", "", twice},
})
}
// step5a drops a final e. Both conditions are tried, unlike every other step,
// because the second is redundant otherwise.
func step5a(w string) string {
if !strings.HasSuffix(w, "e") {
return w
}
s := w[:len(w)-1]
if m := measure(s); m > 1 || (m == 1 && !endsCVC(s)) {
return s
}
return w
}
// step5b reduces a doubled l.
func step5b(w string) string {
return apply(w, []rule{
{"ll", "l", func(string) bool { return measure(w[:len(w)-1]) > 1 }},
})
}
// grown reports that a stem has at least one vowel-consonant pair behind it.
func grown(s string) bool { return measure(s) > 0 }
// twice reports that a stem has at least two.
func twice(s string) bool { return measure(s) > 1 }
// consonant reports whether the letter at i is a consonant. A y is a
// consonant when what precedes it is not, which for a run of them alternates.
func consonant(w string, i int) bool {
if vowel(w[i]) {
return false
}
if w[i] != 'y' {
return true
}
flip := false
for i > 0 && w[i] == 'y' {
flip = !flip
i--
}
return !vowel(w[i]) != flip
}
func vowel(b byte) bool {
return b == 'a' || b == 'e' || b == 'i' || b == 'o' || b == 'u'
}
// measure counts the vowel-consonant pairs in a stem, Porter's m.
func measure(s string) int {
m, wasVowel := 0, false
for i := range len(s) {
c := consonant(s, i)
if c && wasVowel {
m++
}
wasVowel = !c
}
return m
}
func hasVowel(s string) bool {
for i := range len(s) {
if !consonant(s, i) {
return true
}
}
return false
}
func endsDouble(w string) bool {
return len(w) >= 2 && w[len(w)-1] == w[len(w)-2] && consonant(w, len(w)-1)
}
// endsCVC reports Porter's *o: consonant, vowel, consonant, where the last is
// not w, x or y. NLTK admits a two-letter vowel-consonant word as well.
func endsCVC(w string) bool {
n := len(w)
if n >= 3 && consonant(w, n-3) && !consonant(w, n-2) && consonant(w, n-1) {
if last := w[n-1]; last != 'w' && last != 'x' && last != 'y' {
return true
}
}
return n == 2 && !consonant(w, 0) && consonant(w, 1)
}
// irregular is the table NLTK carries of forms the rules get wrong: twenty
// years of corrections sent to Martin Porter.
var irregular = map[string]string{
"sky": "sky", "skies": "sky",
"dying": "die", "lying": "lie", "tying": "tie",
"news": "news",
"innings": "inning",
"inning": "inning",
"outings": "outing",
"outing": "outing",
"cannings": "canning",
"canning": "canning",
"howe": "howe",
"proceed": "proceed",
"exceed": "exceed",
"succeed": "succeed",
}