Skip to content

feat(lexicon): learn multi-word phrases, not just single tokens - #45

Merged
elecnix merged 3 commits into
mainfrom
feat/lexicon-phrases
Aug 8, 2026
Merged

feat(lexicon): learn multi-word phrases, not just single tokens#45
elecnix merged 3 commits into
mainfrom
feat/lexicon-phrases

Conversation

@elecnix

@elecnix elecnix commented Aug 8, 2026

Copy link
Copy Markdown
Owner

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:

term verdict but in context
laisse neutral laisse tomber — "forget it"
tomber neutral
trop neutral trop lent — "too slow"
lent neutral

Every 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 again all 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 existing revises lineage, and the existing one-node-per-(turn, signal) additivity — no new machinery.

  • Phrases are extracted as adjacent bigrams only — a width-2 sliding window over the observed token stream, never a permutation set. A segment of n tokens yields n−1 candidates, not n(n−1); order comes from the text, so laisse tomber is a candidate and tomber laisse is not.
  • Built within a sentence only. fix it. laisse tomber must not yield it laisse — that is a seam, not speech. tokenizeSegments splits on .!?;:\n while keeping commas inside, so putain, c'est faux stays one segment.
  • turn-frustration matches 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:

run 1: nodesProduced=61   LLM calls=60
run 2: nodesProduced=60   LLM calls=120
run 3: nodesProduced=60   LLM calls=180
run 4: nodesProduced=60   LLM calls=240

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:

phrase sessions
in the, of the, do not 400–700
laisse tomber 1
trop lent 1

It 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-candidates and turn-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 test427 passing. New unit tests for tokenizeSegments / 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.ts21 passing.
  • npx tsc --noEmit clean.
  • Verified against the real 1,533-session corpus for the measurements quoted above.

One test-helper fix was needed: classifyCallsFor matched TERM: putain by substring, so TERM: putain c'est counted as a second adjudication of the single word. Now matched exactly.

🤖 Generated with Claude Code

elecnix and others added 3 commits August 8, 2026 14:03
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Frustration lexicon: learn multi-word phrases, not just single tokens

1 participant