Define one boundary rule for Regulars a Side quest's reason names
CI / ci (push) Successful in 2m38s
CI / ci (pull_request) Successful in 2m25s

The reason check split on white space and trimmed punctuation before
looking a phrase up, so a stored name ending in punctuation could be
trimmed into a different Regular (MQ-100-4) and a name joined to the
next word by punctuation was never seen (MQ-100-5).

A mention of a Regular is now where the reason spells its name, ignoring
case and spacing, with the name's own outer punctuation optional and no
letter or digit just outside it. A mention names its Regular unless a
different Regular's mention covers the same text or more, which keeps a
mention of "Easy Beef Chili" from naming "Beef Chili" (MQ-100-3).
One table-driven test covers the whole matrix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 22:21:31 -04:00
co-authored by Claude Opus 5.5
parent 622f5359e8
commit 97eb97582d
2 changed files with 104 additions and 15 deletions
+56 -15
View File
@@ -209,6 +209,7 @@ func (s suggestSideQuests) PostProcess(out SideQuests) (SideQuests, error) {
regulars.add(r.Name)
names = append(names, r.Name)
}
regulars.all = names
known := harness.NewDeduper(harness.DedupeSources{
Regulars: names,
QuestLog: s.in.Context.QuestLog,
@@ -245,6 +246,8 @@ type regularNames struct {
exact map[string][]string
// normalized is keyed by harness.Normalize of the name.
normalized map[string][]string
// all holds every Regular's name, in the household's order.
all []string
}
func exactName(name string) string { return strings.ToLower(oneLine(name)) }
@@ -309,25 +312,63 @@ func drop(q SideQuest, regulars regularNames, known *harness.Deduper, kept []Sid
return cited, ""
}
// named returns the Regulars text names, each found by find. Reading from
// the left, the longest run of words find takes for one Regular, its outer
// punctuation trimmed, is the Regular named there; a run naming several
// Regulars names none. So "Your Easy Beef Chili." names "Easy Beef Chili"
// and not "Beef Chili".
// named returns the Regulars text names, by one rule. A mention of a
// Regular is a place where text spells its name, ignoring case and spacing,
// with the name's own outer punctuation optional ("Easy Beef Chili!" is
// mentioned by "Easy Beef Chili." too) and no letter or digit just outside
// it: the start or end of text, white space or punctuation must border it,
// so "Beef Chili-inspired" and "Beef Chili's" mention "Beef Chili" while
// "Beef Chilis" does not. A mention names its Regular unless a mention of a
// different Regular covers the same text or more, so "Your Easy Beef
// Chili." names "Easy Beef Chili" and not "Beef Chili", and text that two
// Regulars spell alike names neither there.
func (r regularNames) named(text string) []string {
words := strings.Fields(text)
var names []string
for i := 0; i < len(words); i++ {
for j := len(words); j > i; j-- {
run := strings.TrimFunc(strings.Join(words[i:j], " "), func(c rune) bool {
return !unicode.IsLetter(c) && !unicode.IsDigit(c)
})
if name, why := r.find(run); why == "" {
names = append(names, name)
i = j - 1
type mention struct {
name string
start, end int
}
text = exactName(text)
var mentions []mention
for _, name := range r.all {
full := exactName(name)
core := strings.TrimFunc(full, isNotWord)
if core == "" {
continue
}
lead := full[:len(full)-len(strings.TrimLeftFunc(full, isNotWord))]
trail := full[len(lead)+len(core):]
for at := 0; ; {
i := strings.Index(text[at:], core)
if i < 0 {
break
}
start, end := at+i, at+i+len(core)
at = start + 1
before, _ := utf8.DecodeLastRuneInString(text[:start])
after, _ := utf8.DecodeRuneInString(text[end:])
if (start > 0 && !isNotWord(before)) || (end < len(text) && !isNotWord(after)) {
continue
}
if strings.HasSuffix(text[:start], lead) {
start -= len(lead)
}
if strings.HasPrefix(text[end:], trail) {
end += len(trail)
}
mentions = append(mentions, mention{name, start, end})
}
}
var names []string
for _, m := range mentions {
covered := slices.ContainsFunc(mentions, func(o mention) bool {
return o.name != m.name && o.start <= m.start && o.end >= m.end
})
if !covered && !slices.Contains(names, m.name) {
names = append(names, m.name)
}
}
return names
}
// isNotWord reports whether c is neither a letter nor a digit.
func isNotWord(c rune) bool { return !unicode.IsLetter(c) && !unicode.IsDigit(c) }
@@ -226,6 +226,54 @@ func TestSuggestSideQuestsReasonNamesResolvedRegular(t *testing.T) {
})
}
func TestSuggestSideQuestsReasonMentionRule(t *testing.T) {
const none = "reason names none of its cited Regulars"
for name, tc := range map[string]struct {
extra []string // Regulars added to household's
reason string
cited string
why string // "" when the Side quest is kept
}{
"exact name": {nil, "Like your Beef Chili, gone green.", "Beef Chili", ""},
"other case and spacing": {nil, "Like your beef CHILI, gone green.", "Beef Chili", ""},
"sentence punctuation": {nil, `Green, like "Beef Chili" (yours).`, "Beef Chili", ""},
"possessive": {nil, "Beef Chili's spices, gone green.", "Beef Chili", ""},
"joined by punctuation": {nil, "A Beef Chili-inspired stew.", "Beef Chili", ""},
"no mention": {nil, "Grilled skewers with a peanut sauce.", "Beef Chili", none},
"letters run on": {[]string{"Ham"}, "Like your Hamburgers, glazed.", "Ham", none},
"stored punctuation written": {[]string{"Easy Beef Chili!"}, "Your Easy Beef Chili! over corn chips.", "Easy Beef Chili!", ""},
"stored punctuation as period": {[]string{"Easy Beef Chili!"}, "Corn chips under your Easy Beef Chili.", "Easy Beef Chili!", ""},
"inside stored punctuation": {[]string{"Easy Beef Chili!"}, "Your Easy Beef Chili! over corn chips.", "Beef Chili", none},
"inside before period": {[]string{"Easy Beef Chili!"}, "Corn chips under your Easy Beef Chili.", "Beef Chili", none},
"longer name cited": {[]string{"Easy Beef Chili"}, "Your Easy Beef Chili over corn chips.", "Easy Beef Chili", ""},
"shorter inside longer": {[]string{"Easy Beef Chili"}, "Your Easy Beef Chili over corn chips.", "Beef Chili", none},
"longer not mentioned": {[]string{"Easy Beef Chili"}, "Your Beef Chili over corn chips.", "Easy Beef Chili", none},
"shorter also on its own": {[]string{"Easy Beef Chili"}, "The Beef Chili, not the Easy Beef Chili.", "Beef Chili", ""},
"punctuation-only difference": {[]string{"Beef Chili!"}, "Your Beef Chili! over corn chips.", "Beef Chili!", ""},
"bare name inside punctuated": {[]string{"Beef Chili!"}, "Your Beef Chili! over corn chips.", "Beef Chili", none},
"spelled alike by both": {[]string{"Beef Chili!"}, "Corn chips under your Beef Chili.", "Beef Chili", none},
} {
t.Run(name, func(t *testing.T) {
in := household
in.Context.Regulars = slices.Clone(household.Context.Regulars)
for _, r := range tc.extra {
in.Context.Regulars = append(in.Context.Regulars, harness.RegularDinner{Name: r, Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30})
}
var f llm.Fake
f.QueueGenerate(reply(batch(sideQuest("Frito Pie", tc.reason, tc.cited))))
out, _, err := suggest(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
result := SideQuests{Suggestions: []SideQuest{want("Frito Pie", oneLine(tc.reason), tc.cited)}}
if tc.why != "" {
result = SideQuests{Suggestions: []SideQuest{}, Dropped: []DroppedSideQuest{{Name: "Frito Pie", Why: tc.why}}}
}
assertSideQuests(t, out, result)
})
}
}
func TestSuggestSideQuestsDuplicatesDropped(t *testing.T) {
declined := sideQuest("The Best Homemade Chicken Tikka-Masala", "Spiced like your Chicken Fajitas.", "Chicken Fajitas")
oldDeclined := sideQuest("Fish Tacos!", "Tortillas like your Chicken Fajitas.", "Chicken Fajitas")