Skip to content

Releases: immortalbob/Mnemolis

Mnemolis v3.56.1

Choose a tag to compare

@immortalbob immortalbob released this 05 Jul 21:51
a543d77

[3.56.1]
Fixed — fusion no longer carries history's honest abstentions as noise

Found while CLI-verifying v3.56.0 for release: "what are the latest AI trends" and "tell me about the history of ancient rome" correctly avoided the keyword triggers (those regression pins held), but the LLM fusion picker included the history source anyway — its own NAME attracts it for those phrasings — and its plain-prose "I couldn't identify which sensor or metric you mean…" abstention was fused into otherwise-good answers as a trailing noise block.

Fixed with the project's established two-part pattern (the discourse-framing precedent explicitly rejects description-nudging as the sole lever):

The source description is corrected where it was genuinely inaccurate — it now states it covers THIS house's own sensors and services only, NOT world history, general knowledge, news, or public/industry trends.
The verifiable lever: history's five honest-abstention messages ("couldn't identify which sensor", "couldn't identify which kind of event", the ambiguity ask, "requires temporal pattern detection", "no recorded metrics yet") joined _looks_empty()'s canonical phrase list, so fusion drops them exactly as it drops any other member with nothing to contribute. The ** structural guard means no real history answer (headers are always **{name} — …**) can ever match; the plain-prose event-count answers ("1 opening today, …") were verified to survive the new phrases, with pins.

Field-verified before tagging: both genre queries re-run against a live deployment came back with no trailing [HISTORY] block, on both fresh phrasings and the original cached-then-expired ones.
Changed

Dependency: mcp[cli] 1.27.2 → 1.28.1 (Dependabot). Full test suite — including the MCP transport tests — run against 1.28.1 before packaging, not just trusted from the PR.
Version bumped to 3.56.1. Test suite: 1697 passing (3 new pins for the abstention/_looks_empty contract), ruff clean. One stale comment fixed (cache_ttl_history_seconds referenced the removed sampling-interval setting).

Mnemolis v3.56.0

Choose a tag to compare

@immortalbob immortalbob released this 05 Jul 21:28
ab95761

[3.56.0]

Added — the history source: time-series memory for the house (opt-in, off by default)

The second Fable Capability Extension to ship (Design Doc 5, all three of its milestones landed together in this release). The Snapshot Engine polls Home Assistant every few minutes, formats the result for diffing, and throws the numbers away — every poll carried a live office-CO2 reading, a room temperature, a battery percentage, and the moment the diff was computed they were gone. The new history source keeps them. Where changes answers "what's different since X," history answers "what were the actual values over X": highs, lows, averages, counts, and trends over real recorded samples. See the new History-and-Trends wiki page.

Sampling never degrades what exists. The sampler (app/history.py) piggybacks on the same home_assistant._get_states() fetch snapshot_ha already makes — enabling the feature adds zero HA load. Numeric sensors passing the device-class filter (HISTORY_DEVICE_CLASSES, plus HISTORY_EXTRA_ENTITIES allowlist / HISTORY_EXCLUDE_ENTITIES denylist) land in WAL-mode /app/data/history.db — one metric_samples row per sensor per tick, plus a metric_catalog upsert that tracks name/area/unit drift while preserving first_seen. One derived pair per tick parses the existing uptime text into uptime.services_up/uptime.services_total; forecast is deliberately not sampled (a prediction recorded as history would be quiet dishonesty). Retention is explicit and batched: HISTORY_RETENTION_DAYS (90) pruned in the sampler tick via a DELETE … LIMIT loop so a large purge never balloons the WAL. battery is in the default class set on purpose — draining-battery questions for free; the button-cell catalog noise is the exclude-list's job, not a reason to drop the class.

Deterministic answers, honest coverage. The routable adapter (app/sources/history.py) resolves three things by pattern against closed vocabularies — metric (friendly-name on word boundaries, longest-first → area+class → bare class → area, asking rather than guessing on ties), window (see below), aggregation (min/max/avg/trend/summary; counts go to the events leg) — with no LLM anywhere in the aggregation path. Every answer leads with the same Low/High/Average/Now line with real units and local clock times. The single most important honesty property: when the requested window reaches back further than the oldest recorded sample, the answer states its actual coverage — "this week (only the past 3 days of recorded data)" — because history only knows what it recorded while it was on. A trend is a claim, so it's gated twice: HISTORY_TREND_MIN_SAMPLES (12) below which no direction is ever asserted, and HISTORY_TREND_MIN_DELTA (0.1) — a per-window, unit-free noise floor the fitted least-squares change must clear, or the finding is "roughly flat" (a finding, not a shrug). Pure-Python slope, the temporal miner's no-numpy discipline.

Events are not duplicated. Count/when questions ("how many times did the front door open today"7 openings today, most recently 9:20 PM.) read the temporal feature's temporal_events table directly, read-only (?mode=ro) — exactly one thing in Mnemolis extracts events from raw state, and it's the temporal miner. Consequence stated rather than hidden: event counts require TEMPORAL_PATTERN_DETECTION_ENABLED=true, and with it off the answer says event history is unavailable instead of returning a confident zero.

The shared window owner. Both changes and history resolve "today," "last night," "since Monday" into a time window, so that logic was extracted to a single owner: timeutil.resolve_window(), returning a real bounded (start, end, label) plus the hours-since float. _resolve_changes_hours() is now a thin adapter over it, byte-identical for every phrase it previously resolved (the canonical float is carried on the result, never re-derived from the timedelta, so FP re-rounding can't drift it; the 24 existing regression tests pinning exact values pass unchanged). New bounded phrases — "last night," "yesterday morning/afternoon/evening," "over the weekend," "since {weekday}," "last N days" — enrich both sources; time-of-day resolution is now DST-correct (aware local arithmetic instead of naive hour subtraction).

Routing, endpoints, health. history registers in SOURCE_MAP/SOURCE_DESCRIPTIONS/CACHE_TTL (CACHE_TTL_HISTORY_SECONDS, 300 — the data can't advance faster than the sampler) with no fallback chain (no other source can answer recorded history) and value/trend/count intent triggers deliberately non-overlapping with changes (a census test pins the disjointness). Two new endpoints under require_api_key: GET /history/metrics (the catalog with per-metric counts and coverage — the /areas analogue) and GET /history/series?metric=&hours= (raw (value, ts) series — the read a future NOC/device-registry dashboard renders sparklines from, shipped now so that dashboard needs no follow-up release). /health gains a history block: disabled when off, else ok/stale/never_ran plus metrics_tracked, samples_24h, db_mb, and quiet_sensors — a catalog entry gone silent (a Mnemovox node dropping off) becomes visible for free. history.db joins the backup set (seven data files now).

Two documented deviations from the design doc as written:

  • Bare "history" is not an intent trigger — it collides with encyclopedic phrasing ("tell me about the history of ancient rome" must stay kiwix; a regression test pins it). The specific value/trend/count phrasings carry the intent without the false positive.
  • "this week" stays a rolling 168h window rather than the doc's local-Monday semantics. changes routes on a substring trigger then resolves the whole query, so redefining the phrase would silently change the shipped, regression-pinned behavior of "what changed this week" — the doc's own byte-identity constraint wins over its nicer calendar semantics. Recorded in resolve_window()'s header so it isn't re-litigated.

Changed

  • Version bumped to 3.56.0. Test suite: 1645 passing (from 1518 at v3.55.2 — 127 new: 63 engine, 33 adapter, 14 shared-window incl. DST and byte-identity pins, 9 routing/census, 5 endpoint/health, 3 Hypothesis properties incl. constant-series-is-never-a-direction), ruff clean. New config: HISTORY_ENABLED, HISTORY_SAMPLE_INTERVAL_MINUTES, HISTORY_RETENTION_DAYS, HISTORY_DEVICE_CLASSES, HISTORY_EXTRA_ENTITIES, HISTORY_EXCLUDE_ENTITIES, HISTORY_TREND_MIN_SAMPLES, HISTORY_TREND_MIN_DELTA, HISTORY_STALE_GRACE_MULTIPLIER, CACHE_TTL_HISTORY_SECONDS. Wiki: new History-and-Trends page; Home, Sources, Configuration-Reference, Health-and-Observability, Roadmap, and Backup-and-Restore updated. README sources/config tables and docker-compose.example.yml updated.
  • One incidental robustness fix found by the new adapter tests: catalog friendly-name matching uses word boundaries (the _detect_area discipline), so a short name like AC can't false-match inside an unrelated word.

Fixed — post-implementation audit, same release (none of this shipped broken; all caught before tag)

A deliberate adversarial pass over the freshly written feature, before release, found eleven real issues — the most important being that the implementation violated the design doc's own priority-1 constraint:

  • The sampler wasn't actually piggybacking. Constraint #1 says "the same _get_states() fetch — zero additional HA API load"; the first cut registered its own scheduler job that re-fetched _get_states() (duplicating what snapshot_ha had just retrieved — the exact pattern the Full Audit Pass once removed from snapshot_ha itself, whose comment memorializes it), rendered the HA area-map template every tick, and did a live Uptime Kuma client login every 5 minutes when snapshot_uptime fetches identical text every 2. (The doc also says "register sample_metrics() on the scheduler," which conflicts with its own constraint #1; its explicit priority ordering says #1 wins.) Now: snapshot_ha()/snapshot_uptime() hand their already-fetched payloads to history.ingest_ha_states()/ingest_uptime_text() — history makes zero fetches of its own; the area map is cached, refreshed at most ~hourly, keeping the stale copy on refresh failure. HISTORY_SAMPLE_INTERVAL_MINUTES is removed (a knob that couldn't change when data arrives would be a lie); /health staleness references the real HA snapshot interval. Hook-level tests pin "exactly one states fetch per snapshot" and "a history failure cannot fail the snapshot."
  • NaN/inf sensor states were stored. float("nan") parses, so a template sensor stuck on NaN would have silently poisoned every average and disabled every min/max it touched. _parse_float() now requires a finite value; property tests unaffected, new pins added.
  • Bare "sun"/"sat" resolved as weekdays"is the sun out today" became since Sunday (90h), hijacking the query's own "today". Weekday abbreviations now require a since/on/last/this prefix; full names still match bare.
  • "last friday" asked on a Friday meant this morning, not a week ago. last + today's weekday now goes 7 days back; bare/since forms keep meaning today's own 00:00.
  • "this afternoon" was lumped into the 18:00 evening branch — a 1 PM afternoon question looked back to yesterday evening under a mislabeled window. Now anchors at noon with its own label (no changes byte-identity concern: the pre-3.56.0 resolver had no afternoon branch, so no pinned value existed).
  • Event verbs matched as raw substrings — "clock" and "blocked" matched lock, so *"how man...
Read more

Mnemolis v3.55.2

Choose a tag to compare

@immortalbob immortalbob released this 04 Jul 17:24
bfc04c2

[3.55.2]

Changed — synthesis prose reads naturally, not like a feed rundown

Found on the voice soak: a detailed news summary narrated the shape of the feed — "One article discusses… Another piece reflects… There is also coverage of…" — instead of just telling you the news. The model does this because the material arrives as discrete sections and nothing told it to dissolve them. Two prompt steers fix it, both prompt-level (a gate on prose phrasing would reject good answers, so there deliberately isn't one):

  • A universal rule in every synthesis prompt: state the information directly, never describe the source material itself ("one article", "another piece", "the sources say", "there is coverage of") — report what happened, not that something reported it. This applies to every style, including digest, whose lines should state the item, not narrate it.
  • The detailed and brief styles now ask for flowing, connected prose that weaves related points into one smooth summary rather than addressing each item separately — the "connect, don't enumerate" half that distinguishes a fused summary from digest's list. digest is untouched: its job is to preserve and enumerate, and the fluency steer is explicitly kept out of it.

No config, gate, cache, or contract changes; the numeric/attribution/echo gates and the additive fallback are exactly as before. Prompts steer rather than guarantee — expect markedly more fluid prose, not surgically perfect.

Changed

  • Defaults flipped now that both features have soaked: SYNTHESIS_ENABLED now defaults true (opt-out) — synthesis is additive by contract and gated per-request, so default-on costs nothing for clients that don't ask; and ADVERSARIAL_TEST_ENABLED now defaults false (opt-in) — it's a diagnostic that generates extra background traffic against the LLM/SearXNG/Kiwix backends, so it stays off unless asked for.
  • Version bumped to 3.55.2. Test suite: 1518 passing (from 1514 at v3.55.1), ruff clean.

Mnemolis v3.55.1

Choose a tag to compare

@immortalbob immortalbob released this 04 Jul 13:44
c01ff70

[3.55.1]

Added — the digest answer style

Found on the first real voice soak: asking a satellite to "summarize the news" with answer_style=voice returned a single story. That was synthesis working exactly as told — voice caps the answer at ~2 sentences, and the model faithfully compressed ten headlines into two, leading with the top item and gesturing at the rest. Nothing hallucinated; the length budget just destroyed nine-tenths of the point. The three existing styles (voice/brief/detailed) all ask the model to answer the question by fusing the material into one reply — right for a fact question, wrong for a breadth request.

New answer_style="digest" inverts the framing for "summarize / list / read me everything" queries: it instructs the model to list each distinct item or key point as its own short line, preserving specific names, numbers, and dates, without merging or dropping items. It carries two larger budgets than the other styles because preservation is the whole job — SYNTHESIS_DIGEST_MAX_CHARS (3000) on output so many items survive, and SYNTHESIS_DIGEST_INPUT_BUDGET_CHARS (12000) on input so a wide result (ten headlines, a broad fusion) reaches the model before per-section apportioning trims it. Exposed on /search, the MCP search tool, and (v1.4.1+) the mnemolis_intents HA tool so the conversation agent can pick digest for list-shaped questions while keeping voice for everything else.

The synthesis gates are unchanged and, for digest, the numeric-grounding gate is more load-bearing, not less: a dense multi-item digest carries many figures, and every one must still appear verbatim in the source or the answer is rejected and falls back to the raw result — the fidelity check that stops a "summary" from inventing a number. Attribution stays whole-answer, not per-item (the same precision-theater non-goal that rules out per-sentence citations).

Changed

  • Version bumped to 3.55.1. Test suite: 1514 passing (from 1508 at v3.55.0), ruff clean. New config: SYNTHESIS_DIGEST_MAX_CHARS, SYNTHESIS_DIGEST_INPUT_BUDGET_CHARS. Wiki Answer-Synthesis.md and Configuration Reference updated.

Mnemolis v3.55.0

Choose a tag to compare

@immortalbob immortalbob released this 04 Jul 12:07
11a8c23

[3.55.0]

Added — Grounded Answer Synthesis (opt-in, off by default)

Mnemolis retrieves; until now it never answered. Every response was source text — a truncated Kiwix article, a scored web-result list, a fused blob of [SOURCE — DESCRIPTION] sections — and turning that into an answer was the client's job. That's fine for Open WebUI and Claude Desktop; it's bad for the voice pipeline, where HA's TTS reads fused headers and headline excerpts aloud while the Beast's 4090 sits one hop away doing nothing but routing.

New synthesize=true on /search (and the MCP search tool) has the local LLM compose a short, grounded answer from the retrieved material only, returned in a separate answer field alongside the raw result — never instead of it. answer_style{voice, brief, detailed} controls length (voice ≈ ≤2 sentences for TTS). See the new Answer-Synthesis.md wiki page and Design Doc 4.

The whole feature is additive by contract. With SYNTHESIS_ENABLED false (the default for this release) or any request that doesn't opt in, the response is byte-identical to v3.54.2 and the three new fields (answer/answer_sources/synthesized) are null/[]/false. Any failure — timeout, empty reply, gate rejection, LLM unconfigured — yields answer: null plus a synthesis_skipped/synthesis_rejected explanation event, leaving the caller with exactly today's raw result.

Grounded or silent, enforced by gates, not hope. Synthesis runs in route_query() after retrieval, inside the same _ROUTE_STATS context so its events join the explanation chain. Pre-flight skips the LLM call entirely for empty/error results, sub-200-char results (a one-line "Front Door: locked" is already the ideal voice answer — this exempts most ha/uptime traffic), and changes output (already prose). The generation then passes a stack of gates: empty-reply rejection; the honest NOT_IN_SOURCES"The retrieved sources don't answer this." success path; attribution parse (multi-source answers that can't say who said what are rejected); echo guard; a numeric-grounding check (every number in the answer must appear in the material after comma-normalization, the current year exempted — a miss rejects and logs the offending token at WARNING, the live-verification hook); and a sentence-boundary length backstop per style.

Details worth noting:

  • Header parsing reuses fusion.HEADER_PATTERN/HEADER_SEPARATORexported from fusion.py, not re-derived — so a header-format change can't drift the synthesis parser away from the producer (the _looks_empty() cross-file-drift bug is the cautionary tale; a drift test pins the contract).
  • New llm.generate() path, deliberately separate from complete(): complete()'s trailing-period stripping and last-line-of-thinking salvage are correct for one-token routing picks but actively wrong for a real answer, so generate() returns an empty response honestly as None (gate 1 rejects) rather than salvaging ungrounded chain-of-thought.
  • Synthesized answers cache under a source-led key so they inherit the underlying source's TTL and can never outlive the retrieval they ground; the NOT_IN_SOURCES miss is never cached (the Cached Failure Bug found three times).
  • /logs/stats gains a synthesis block from a new nullable synthesized status column (migration-safe guarded ALTER TABLE); Locust gains a dedicated [synthesize] bucket so the added generation cost shows honestly against [auto], not smeared into it.

Changed

  • Version bumped to 3.55.0. Test suite: 1508 passing (from 1453 at v3.54.2), ruff clean. New config: SYNTHESIS_ENABLED, SYNTHESIS_TIMEOUT_SECONDS, SYNTHESIS_MODEL, SYNTHESIS_INPUT_BUDGET_CHARS, SYNTHESIS_MIN_INPUT_CHARS, SYNTHESIS_MAX_CHARS, SYNTHESIS_VOICE_MAX_CHARS.

Mnemolis v3.54.2

Choose a tag to compare

@immortalbob immortalbob released this 03 Jul 15:50
744ed25

[3.54.2]

Fixed — Disambiguation Candidates Replaced the Plain Search Term Instead of Adding to It

Live verification of v3.54.1 found "what is a galaxy" returning "Galaxy morphological classification" — and an explanation-chain trace (a single source_invoked at 6310ms: the disambiguation machinery's signature) plus a code read isolated the cause in about a minute: during disambiguation against a Wikipedia book, terms_for_book = disambiguation_candidates REPLACED the plain search terms entirely. The bare term "galaxy" was never searched, so the plain "Galaxy" article never even entered the candidate pool — and scoring, including v3.54.1's content-exact +20 built for exactly this shape of query, can only rank what was fetched. The candidates are specific sub-sense phrasings BY DESIGN; the previous behavior bet everything on the LLM having guessed the right senses, when the plain sense is the one candidate that's always legitimate — and for a definitional ask, usually the answer.

The plain search terms are now pooled FIRST, ahead of the candidates, with a dedup against any candidate identical to the bare term (the LLM sometimes returns it as one — a blind prepend would have searched it twice, caught by an existing test the fix initially broke). Cost: one extra same-LAN Kiwix request per disambiguating Wikipedia book; the existing URL dedup absorbs any result overlap. Verified against the reproduced live shape: plain "Galaxy" now scores 45 (content-exact +20 finally reachable AND fetched) vs the classification article's 25. One pre-existing test updated to pin the pooled term order; 2 new regression tests including the live incident end to end and the LLM-repeats-the-bare-term dedup.

Worth noting the compounding: v3.54.1 made the exact-title signal scoreable and this makes it reachable — the two halves of the same fix, found two days apart because each one's verification exposed the next layer. This is also the third consecutive release where a casual live CLI query found a real bug the 1400+-test suite structurally couldn't — the suite pins mechanisms; the live index supplies adversarial candidates nobody would invent.

Changed

  • Version bumped to 3.54.2. Test suite: 1453 passing (from 1451 at v3.54.1), ruff clean.

Mnemolis v3.54.1

Choose a tag to compare

@immortalbob immortalbob released this 03 Jul 11:10
275feb4

[3.54.1]

Fixed — "What Is the New Deal" Returned Deal, New Jersey: Three Compounding Scoring Defects

Live CLI verification of v3.54.0 found the v3.53.1 term-building fix had done its half (Kiwix received new deal, not the literal new) but the failure had moved downstream: the response was Deal, New Jersey. Checking Kiwix's own engine directly showed the genuine "New Deal" article at position 5 of its results — well inside the 15 Mnemolis fetches — so Mnemolis's scoring out-ranked it, 20 to the borough's 25. (The same check also surfaced an unrelated operational find: a corrupt asheron-fandom-test4.zim leftover was 500ing kiwix-serve's unscoped search endpoint entirely; Mnemolis was unaffected since it always scopes by book, but the file needed removing.)

Reproducing the scoring against the real candidate list isolated three compounding defects in _score_result():

  1. The +20 exact-title match was effectively unreachable. It compared the title against the FULL raw query only — and "what is the new deal" is never a Wikipedia title, so the rubric's strongest signal couldn't fire for any naturally-phrased question. Now compared against the full query OR its content words ("what is the new deal" → "new deal" == the title), with a stemmed content-sequence variant at +15.
  2. The +10 starts-with bonus was a raw string prefix. title_lower.startswith(w) handed "Deal, New Jersey" a bonus the exact-title article couldn't earn ("new" fails the len>3 filter), and would match "Dealership" for "deal" the same way. Now token-exact: the title's FIRST token, punctuation-stripped and stemmed, must equal a meaningful query word.
  3. Title/excerpt tokenization never stripped punctuation. "Deal," (comma attached) stems to "deal,", not "deal" — the borough was only credited ONE title hit for a two-word overlap, and "(New" / "show)" fragments had the same problem. Tokens are now punctuation-stripped before the hit sets are built; this honestly RAISES some wrong candidates' scores (the borough now has both words), which is exactly why fix 1 exists to dominate them.

Post-fix on the live candidate list: New Deal 40, the borough and the game show 30, everything else 20. All prior scoring incidents re-verified intact (the molybdenum element now earns the full +20 content-exact match instead of +15 stemmed; galaxy disambiguation unchanged). 4 new regression tests including the live candidate list pinned end to end, the Dealership prefix false-positive, and punctuation-glued tokens counting as hits; the rubric docstring and wiki/Kiwix-Scoring.md's point table both corrected to describe the real conditions.

Changed

  • Version bumped to 3.54.1. Test suite: 1451 passing (from 1447 at v3.54.0), ruff clean.

Mnemolis v3.54.0

Choose a tag to compare

@immortalbob immortalbob released this 03 Jul 10:54
754545c

[3.54.0]

Added — Semantic Cache Startup Warmup

The semantic routing store is deliberately in-memory only (design constraint 3 — persisting float vectors would mean a new on-disk format, model-change versioning, and staleness coupling with routing_cache.json), which meant every container restart put rephrasings back on the cold LLM path until the store lazily repopulated. But routing_cache.json PERSISTS — the queries and decisions survive the restart; only the vectors were missing. A background daemon thread at startup now re-embeds the routing cache's just-loaded source: queries, restoring the full pre-restart matching ability in seconds without persisting a single vector.

Mechanics: new llm.embed_batch() (one HTTP round trip per batch of 32 — both backends accept list input natively — with its own generous timeout, since a background batch shouldn't be governed by the request path's deliberately short EMBEDDING_TIMEOUT_SECONDS); new semantic_routing.warm() (newest-decided first when there are more persisted queries than free slots, skips already-present keys, one failed batch aborts with a warning since every subsequent batch would fail identically — and a partial store is exactly the state lazy population already handles); new router.warm_semantic_routing_cache() owning the two things only the router knows (which cache keys are source: intent decisions vs book/disambiguation entries, and which are still live against ROUTING_CACHE_TTL — warming an expired entry would waste a slot on a candidate find_similar() immediately skips). Started as a daemon thread from the lifespan — deliberately NOT an awaited executor call like the cache loads, since the warmup must never delay startup and talks to a backend that may itself still be booting after a host restart. New setting SEMANTIC_WARMUP_ENABLED (default true). 7 new tests, including the live-TTL/source:-only filtering, newest-first capacity handling, failed-batch abort, and an end-to-end warmed-entry-matches-a-rephrasing check.

One real bug caught during this work, before it ever shipped: the warmup's first draft iterated _routing_cache.items() directly — a live dict view, scanned on a background thread while request traffic is already inserting into that same dict, which raises RuntimeError: dictionary changed size during iteration mid-scan. Fixed with the same list()-snapshot discipline semantic_routing.find_similar() already applies to its own store.

Added — Semantic-Rephrase Benchmark Pool

The benchmark suite now measures the feature v3.53.0 shipped: new SEMANTIC_REPHRASE_TOPICS locustfile pool — six topics, several colloquial rephrasings each — under its own /search [semantic_rephrase] name, so a cold run's per-endpoint table directly shows the semantic cache's latency win against [auto]'s (first phrasing of a topic pays the LLM, later phrasings should land intent_semantic reuses). Phrasings deliberately include the apostrophe-less colloquial forms ("whats the story with") the v3.53.1 term-building fix covers, so a regression in either feature shows up in the same bucket. Against a deployment without EMBEDDING_MODEL it degrades to plain extra auto-shaped load — harmless, just not informative.

Changed

  • wiki/Home.md now links every wiki page: Semantic Routing Cache added under Core Concepts (after Caching) and Explanation Chains under Operations (after Health & Observability), each with a description in the page's established style; audited both directions (no unlinked pages, no dead links).
  • wiki/Semantic-Routing-Cache.md design constraint 3 rewritten for the warmup ("in-memory only — but warmed at startup, not just rebuilt through use"); wiki/Caching.md and wiki/Configuration-Reference.md updated to match.
  • Version bumped to 3.54.0. Test suite: 1447 passing (from 1440 at v3.53.1), ruff clean.

Mnemolis v3.53.1

Choose a tag to compare

@immortalbob immortalbob released this 03 Jul 10:45
1fdc89f

[3.53.1]

Fixed — The "Love Story (1944 film)" Incident: Colloquial Framings Now Strip as Whole Phrases

Explanation chains' very first real-world trace caught a live Kiwix bug: "whats the story with molybdenum" returned Love Story (1944 film) — a movie whose plot happens to involve molybdenum mining — instead of the element. The chain showed routing was correct (intent_llm → kiwix), isolating the failure to Kiwix's own search in about a minute. Two independent leaks, both confirmed by direct execution:

  1. "whats" (no apostrophe) leaked a literal "what" into the search terms. The contraction-normalizing regex in _build_search_terms() only fires on an actual apostrophe; "whats" wasn't a stop word; and _stem() runs after stop-word filtering, so it stemmed the survivor into "what" on the way out. Fixed by adding the apostrophe-less contraction forms (whats, hows, whos, wheres, thats, its, dont, cant, …) to _STOP_WORDS directly — deliberately excluding real-topic homographs (id, im).
  2. "story" was never stripped at all. The colloquial-question handling was word-level ("deal", "keep", "hearing", "up" as bare _STOP_WORDS entries), and "story" wasn't among them. The search Kiwix actually received was what story molybdenum, and the film earned its winning points from "story" in both the search terms AND _score_result()'s title overlap.

The fix re-applies the lesson DISCOURSE_FRAMING_PATTERNS' own comment already documented: strip whole phrases, not individual meaningful words. New COLLOQUIAL_QUESTION_PHRASES removes "what's the story with" / "whats the deal with" / "what is up with" / "keep hearing about" and variants (apostrophe, apostrophe-less, and spelled-out forms — the apostrophe-less form is what actually triggered the incident) as units, applied in both _build_search_terms() and _score_result()'s scoring set. _is_definitional_query() still deliberately receives the original phrasing — those leading phrases are precisely what make such queries definitional, pinned by a test asserting the colloquial phrasing scores identically to formal "what is X".

Auditing the retired word-level entries found they'd been silently breaking every query where those words WERE the topic, confirmed directly: "what is the New Deal" had been searching Kiwix for the literal term new, and "what is a hearing aid" for aid, and "what is up with the up quark" lost half its subject. "deal", "keep", "hear"/"heard"/"hearing", and "up" are no longer stop words; regression tests pin New Deal, hearing aid, and up quark surviving as topics. A knock-on: with "up" restored as a content word, zero INTENT_MAP keyword phrases are stop-word-only anymore (previously exactly two: "is it up", "are they up") — the _filter_meaningful() safety net deliberately stays for future additions, and its census test now pins the empty set with the full story.

11 new term-building tests + 3 new scoring tests, including the exact live incident pinned end to end (the element must out-score the film under the original query — confirmed the film genuinely won before the fix, 15 vs 40 after). wiki/The-Discourse-Framing-Investigation.md gained a postscript, including the operational note that a bad result from this bug persists in the result cache under its exact phrasing until the kiwix TTL — POST /cache/clear after deploying.

Changed — docker-compose.example.yml Gained the LLM and Embedding Sections

The example compose file never had the LLM settings at all (LLM_URL, LLM_MODEL, LLM_API_TYPE, LLM_KEEP_ALIVE), and v3.53.0's embedding settings weren't added either — a fresh GitHub deployment following the example got keyword-only routing with no hint the LLM or semantic layers existed. Both sections added with the same inline-comment guidance style as the rest of the file, including the docker exec ollama ollama pull nomic-embed-text one-liner and a pointer to the Semantic Routing Cache wiki page.

Changed

  • Version bumped to 3.53.1. Test suite: 1440 passing (from 1429 at v3.53.0), ruff clean.

Mnemolis v3.53.0

Choose a tag to compare

@immortalbob immortalbob released this 03 Jul 02:04
6bf309d

[3.53.0]

Added — Semantic Routing Cache

A third, opt-in cache layer between an exact-match routing-cache miss and the routing LLM: embedding-based reuse of routing decisions across rephrasings of the same question ("will it rain later" borrowing "will it rain this evening"'s already-made decision). Targets the last avoidable routing-path latency — the ~200–700ms cold LLM call for a query the exact-match cache has seen only in different words, which for real personal usage (one person asking natural variations of the same few dozen questions) is most cold routing calls.

Design constraints, in priority order (full rationale in app/semantic_routing.py's module docstring and wiki/Semantic-Routing-Cache.md): (1) never make routing worse than v3.52.0 — every failure mode (feature off, embedding call failed, empty store, nothing above threshold) falls through to exactly the previous behavior, and the one failure it can introduce (a wrong reuse silently misrouting) is why SEMANTIC_ROUTING_THRESHOLD defaults conservatively to 0.92; (2) decisions are never stored in the semantic layer — it holds only (query → normalized vector) pairs, decisions are always read back from the LIVE routing cache at match time, expired candidates are skipped and pruned, so one source of truth and one TTL govern everything; (3) in-memory only, rebuilt through use — the query that rebuilds an embedding after a restart was about to pay a full LLM call anyway.

Mechanics: hooked inside _llm_detect()'s singleflight after the exact-match re-check; a hit promotes the new phrasing to a normal exact-match entry (never pays even the embedding call again) and stores its vector for the next rephrasing; a miss reuses the lookup's vector when storing the fresh LLM decision, so the cold path costs at most ONE embedding call total. Model changes drop the store (cross-model cosine similarity is noise), with a per-entry dimension check for the residual case. Vector math is dependency-free: L2-normalize at store time, math.sumprod() dot products at lookup (C-speed on the Dockerfile's pinned 3.12; a full scan of a maxed 500-entry store is sub-millisecond, vs 50–100ms for a pure-Python loop — the reason the scan can honestly return the BEST match rather than early-exiting at the first above-threshold one).

New: app/semantic_routing.py, llm.embed()/llm.embeddings_configured() (Ollama /api/embed and OpenAI-compatible /v1/embeddings, riding llm.py's existing session pool), settings EMBEDDING_MODEL (blank = feature fully disabled, zero network I/O), EMBEDDING_URL, EMBEDDING_TIMEOUT_SECONDS, SEMANTIC_ROUTING_THRESHOLD, SEMANTIC_CACHE_MAX_SIZE, endpoints GET /cache/semantic and POST /cache/semantic/clear. 18 new tests, including the one-embedding-call-total contract, expired-decision pruning, the exact-key self-match exclusion, model-change reset, and the LLM-never-called integration path.

Added — Explanation Chains

/search with "explain": true returns the ordered trace of what routing actually did: intent resolution (keyword / cached / semantic-with-similarity / fresh LLM / default), result-cache hits, every source invocation with its real measured elapsed_ms, fallbacks, decomposition splits, conditional extractions, and fusion dispatches with their source lists. Returned on failures too, as the partial trace of everything that ran before the exception — when a trace earns its keep most.

The first of the project's four original design documents to ship, and deliberately built after the v3.52.0 stats channel rather than before it: _route_event() appends into the same per-request _ROUTE_STATS dict the boolean stats write into, at the same authoritative code points — the explanation is the SAME recording as the stats, never a parallel reconstruction, so it structurally cannot disagree with what actually ran. The original design sketch assumed threading explanation state through route_with_source()'s recursion-laden signature, exactly the change that function's history had twice judged not worth its risk; the ContextVar channel collapsed the feature to "more keys in a dict that already flows everywhere," as The Fallback Observability Gap's postscript predicted.

Ordering and attribution: within one thread, events append in genuine execution order (a fallback reads invoked → fallback → invoked); across concurrent decomposed sub-queries the interleaving is nondeterministic (list.append is GIL-atomic, order isn't), which is why every event carries the query text it belongs to — attribution comes from fields, never list position. Events are always collected (small-dict appends are noise next to real routing work, and it keeps the recording path branch-free) but only returned when asked; explain defaults to false and the response field is null otherwise, so existing clients see one new always-null key and nothing else changes. The MCP search tool is unchanged. New: SearchRequest.explain, SearchResponse.explanation, RouteOutcome.explanation, router._route_event(), wiki/Explanation-Chains.md. 10 new tests, including event-order-within-a-thread, attribution-by-field for concurrent sub-queries, and the partial-chain-on-failure path.

Changed

  • Version bumped to 3.53.0. Test suite: 1429 passing (from 1401 at v3.52.0), ruff clean. wiki/Caching.md, wiki/Configuration-Reference.md, wiki/Roadmap.md (new "Post-Audit Optimization & Observability" era section), and the README (env table, /search and /cache/semantic API docs, test count) all updated.