feat(lexicon): learn multi-word phrases, not just single tokens - #45
Merged
Conversation
Running the lexicon over a real corpus made the limitation concrete: a
French-speaking user's frustration went undetected even though the vocabulary was
nominated correctly. The terms were judged individually and individually they are
all neutral — `laisse`, `tomber`, `trop`, `lent`. Each verdict is *right*. The
signal simply does not live in any single token; it lives in the bigram.
Not French-specific: `never mind`, `forget it`, `come on`, `what the hell`, `not
again` have the same shape. Single-token judgement structurally cannot see any of
them, so this was the largest recall gap left in the lexicon.
Not fixable by prompt. The term prompt deliberately asks about habitual usage
with no session context, because a term node's recipe is the term alone — adding
an example sentence would make identity dishonest and let the first session to
nominate a word fix its verdict for everyone. That constraint stays. Phrases
become first-class subjects instead.
The pleasant part is how little was needed: a phrase *is* a corpus-wide string,
so its source ref stays `term`-kind (words joined by a space) and it inherits the
existing cache, the existing `revises` lineage, and the existing one-node-per-
(turn, signal) additivity for free.
- lexicon-candidates nominates adjacent bigrams under their own budget, since
bigrams vastly outnumber unigrams and would crowd out individually meaningful
vocabulary.
- Phrases are built within a sentence only. `fix it. laisse tomber` must not
yield `it laisse` — that is the seam between two sentences, not something
anyone said. New tokenizeSegments splits on strong boundaries while keeping
commas inside, so `putain, c'est faux` stays one segment.
- turn-frustration matches phrases over the same tokenised segments, so phrase
matching inherits the Unicode-correct, substring-proof guarantees.
A phrase and its component words are independently judged subjects, so both may
fire on one turn; each is a real signal and each gets its own node, which is what
keeps growth additive. Their weights both count toward the ranking sum, which is
only ever used to decide which turns deserve a closer look, never as a threshold.
Version bumps: lexicon-candidates and turn-frustration to 1.1 (behaviour), and
frustration-lexicon to 1.1 (the shipped prompt now covers phrases). Existing
single-word verdicts stay valid and are re-judged only by an explicit
`--revise minor`; upgrading invalidates nothing.
Closes #40
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checking how phrase ranking works surfaced a flaw in the existing per-session cap and, on investigation, showed the obvious fix to be worse than the flaw. The cap was applied at nomination, which is deterministic and graph-blind, so it re-offers the same ranked list every session. Ordinary words therefore occupied every slot permanently. Measured over the real corpus: 92% of nomination slots went to terms already judged, and 62% of sessions hit the cap with vocabulary left unoffered. It was never buying anything, because an already-judged entry is free to re-plan. The tempting fix — budget the *unjudged* entries — makes planning a function of graph state. Each re-run then frees the slots the previous run filled and buys another batch. Measured on one session: 60 further adjudications on every pass, without end. That is a direct violation of "re-running analysis without changing the version, config, or inputs produces no new nodes", so it is rejected, and a test now pins idempotency across repeated passes. What actually bounds spend needs no cap at all: the corpus-wide cache. A session only ever pays for vocabulary no earlier session used, so total spend is the corpus's distinct vocabulary, once. The ceilings stay, raised to a level that does not bind (the largest real session nominated 990 terms), and are documented as node-size bounds rather than budgets. A recurrence floor was also measured and rejected, because it optimises for exactly the wrong tail: `laisse tomber` and `trop lent` — the idioms this feature exists to catch — each occur in a single session, while the bigrams recurring across hundreds of sessions are `in the`, `of the`, `do not`. Filtering by recurrence would discard the signal and keep the noise. Measured cost of the resulting design over 1,533 sessions: 14,321 distinct words and 190,125 distinct phrases, ~204k one-time adjudications at cheap tier, then near zero as the corpus saturates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… verdict
Judging one lexicon term is the simplest classification in the pipeline and runs
hundreds of thousands of times, so it is the obvious place to spend a small model
while per-turn classification keeps a stronger one. That was impossible: the
tier→model mapping is global and there was no per-analyzer config plumbing at
all, so frustration-lexicon and turn-pair-llm were stuck sharing `cheap`.
- prospector.json gains `analyzers`, keyed by analyzer id, merged over each
analyzer's shipped defaults. Whatever is set is `config`, so a change marks
nodes stale for the ungraded `config` reason — never silently reused.
- frustration-lexicon's `tier` now accepts an explicit `provider/model` spec as
well as a tier name; resolveModelSpec already passed those through.
{ "analyzers": { "frustration-lexicon": { "tier": "ollama/gemma4:31b-mlx" } } }
Pointing this analyzer at a weaker model exposed a latent trap, so it is fixed
here too. A missing or unusable reply was being defaulted to a neutral verdict.
Because verdicts are cached corpus-wide and permanently, a model that cannot do
forced tool calls would have silently marked every word in the corpus as ordinary
vocabulary: the feature would appear to run and do nothing, with no external
symptom. It now fails instead, which records an error node, leaves the unit
missing, and self-heals on the next run.
The check is deliberately shape-aware rather than "did JSON parse". A well-formed
object of the wrong kind — which is exactly what several test mocks were
returning for classify_term — would otherwise decay into the same all-neutral
lexicon. Those mocks now answer term calls with a term verdict.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #40
Problem
Running the lexicon over a real corpus made the limitation concrete: a French-speaking user's frustration went undetected, even though the vocabulary was nominated correctly. The terms were judged individually, and individually they are all neutral:
laisselaisse tomber— "forget it"tombertroptrop lent— "too slow"lentEvery one of those verdicts is correct for the token in isolation. The signal simply does not live in any single token. Not French-specific —
never mind,forget it,come on,not againall share the shape.Why not a prompt fix
The term prompt deliberately asks about a token's habitual usage with no session context, because a term node's recipe is the term alone. Feeding it an example sentence would make identity dishonest — the recipe would claim "just the word" while the verdict turned on one session's sentence. That constraint stays; phrases become first-class subjects instead.
What made this small
A phrase is a corpus-wide string, so its source ref stays
term-kind (words joined by a space). It inherits the existing corpus-wide cache, the existingreviseslineage, and the existing one-node-per-(turn, signal) additivity — no new machinery.laisse tomberis a candidate andtomber laisseis not.fix it. laisse tombermust not yieldit laisse— that is a seam, not speech.tokenizeSegmentssplits on.!?;:\nwhile keeping commas inside, soputain, c'est fauxstays one segment.turn-frustrationmatches over those same segments, inheriting the Unicode-correct, substring-proof guarantees.Second commit: the cap was wrong, and so was the obvious fix
Reviewing how ranking worked exposed a flaw in the existing per-session cap, and investigating it showed the tempting fix to be worse.
The cap bought nothing. It applied at nomination, which is deterministic and graph-blind, so the same ranked list is re-offered every session and ordinary words occupied every slot permanently. Measured: 92% of nomination slots went to terms already judged, and 62% of sessions hit the cap with vocabulary left unoffered.
Budgeting the unjudged entries breaks idempotency. It makes planning a function of graph state, so each re-run frees the previous run's slots:
That violates "re-running analysis without changing the version, config, or inputs produces no new nodes." Rejected; a test now pins idempotency across repeated passes.
A recurrence floor optimises for the wrong tail. It would cut phrases 190k → 34k, but the idioms this feature exists to catch are hapaxes:
in the,of the,do notlaisse tombertrop lentIt would discard the signal and keep the noise.
What actually bounds spend is the corpus cache, which needs no cap: a session only ever pays for vocabulary no earlier session used, so total spend is the corpus's distinct vocabulary, once. The ceilings remain as node-size bounds, raised high enough not to bind (largest real session nominated 990 terms).
Measured cost over 1,533 sessions: 14,321 distinct words + 190,125 distinct phrases ≈ 204k one-time cheap-tier adjudications (~$7.80), then near-zero as the corpus saturates. Shipping uncapped is a deliberate recall-over-cost choice.
Versions
lexicon-candidatesandturn-frustration→ 1.1 (behaviour);frustration-lexicon→ 1.1 (prompt now covers phrases). Existing single-word verdicts stay valid and are re-judged only by an explicit--revise minor— upgrading invalidates nothing.Test plan
npm test— 427 passing. New unit tests fortokenizeSegments/rankPhrases/matchPhrases(boundary handling, locale-independent ordering, no cross-sentence bigrams); component tests for the end-to-end case (every component word neutral, turn still flagged via the phrase, phrase cached corpus-wide); and cost tests pinning idempotency and the "pays only for what is new" property.node --import tsx test/integration/test-commands.ts— 21 passing.npx tsc --noEmitclean.One test-helper fix was needed:
classifyCallsFormatchedTERM: putainby substring, soTERM: putain c'estcounted as a second adjudication of the single word. Now matched exactly.🤖 Generated with Claude Code