trajectories 0.0.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/LICENSE +201 -0
- package/README.md +288 -2
- package/dist/cli.js +30210 -0
- package/dist/convert.d.ts +31 -0
- package/dist/convert.js +431 -0
- package/dist/deepagents_checkpoint.py +362 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8402 -0
- package/dist/log-baseline.d.ts +26 -0
- package/dist/log-baseline.js +104 -0
- package/dist/log-content.d.ts +16 -0
- package/dist/log-content.js +80 -0
- package/dist/log-io.d.ts +10 -0
- package/dist/log-io.js +121 -0
- package/dist/log-read.d.ts +24 -0
- package/dist/log-read.js +117 -0
- package/dist/log-reconcile.d.ts +5 -0
- package/dist/log-reconcile.js +38 -0
- package/dist/log-records.d.ts +35 -0
- package/dist/log-records.js +85 -0
- package/dist/log-recovery.d.ts +18 -0
- package/dist/log-recovery.js +123 -0
- package/dist/log-stage.d.ts +14 -0
- package/dist/log-stage.js +138 -0
- package/dist/log-state.d.ts +89 -0
- package/dist/log-state.js +92 -0
- package/dist/log-switch.d.ts +3 -0
- package/dist/log-switch.js +98 -0
- package/dist/log-types.d.ts +129 -0
- package/dist/log-types.js +8 -0
- package/dist/log.d.ts +8 -0
- package/dist/log.js +62 -0
- package/dist/migrate.d.ts +29 -0
- package/dist/migrate.js +173 -0
- package/dist/normalization-runtime.d.ts +1 -0
- package/dist/normalization-runtime.js +3150 -0
- package/dist/portable.d.ts +9 -0
- package/dist/portable.js +26 -0
- package/dist/readers/canonical.d.ts +13 -0
- package/dist/readers/canonical.js +54 -0
- package/dist/readers/cline.d.ts +10 -0
- package/dist/readers/cline.js +107 -0
- package/dist/readers/cursor.d.ts +20 -0
- package/dist/readers/cursor.js +374 -0
- package/dist/readers/deepagents.d.ts +41 -0
- package/dist/readers/deepagents.js +163 -0
- package/dist/readers/fx.d.ts +11 -0
- package/dist/readers/fx.js +573 -0
- package/dist/readers/github-copilot.d.ts +8 -0
- package/dist/readers/github-copilot.js +216 -0
- package/dist/readers/grok-build.d.ts +12 -0
- package/dist/readers/grok-build.js +391 -0
- package/dist/readers/guards.d.ts +4 -0
- package/dist/readers/guards.js +121 -0
- package/dist/readers/pi-compaction.d.ts +7 -0
- package/dist/readers/pi-compaction.js +112 -0
- package/dist/readers/pi-context.d.ts +13 -0
- package/dist/readers/pi-context.js +64 -0
- package/dist/store.d.ts +25 -0
- package/dist/store.js +218 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +1 -0
- package/dist/writers/claude-code.d.ts +5 -0
- package/dist/writers/claude-code.js +51 -0
- package/dist/writers/cline.d.ts +3 -0
- package/dist/writers/cline.js +77 -0
- package/dist/writers/codex.d.ts +3 -0
- package/dist/writers/codex.js +32 -0
- package/dist/writers/cursor.d.ts +3 -0
- package/dist/writers/cursor.js +197 -0
- package/dist/writers/deepagents.d.ts +3 -0
- package/dist/writers/deepagents.js +116 -0
- package/dist/writers/entropy.d.ts +5 -0
- package/dist/writers/entropy.js +33 -0
- package/dist/writers/fx.d.ts +3 -0
- package/dist/writers/fx.js +135 -0
- package/dist/writers/github-copilot.d.ts +3 -0
- package/dist/writers/github-copilot.js +71 -0
- package/dist/writers/grok-build.d.ts +3 -0
- package/dist/writers/grok-build.js +43 -0
- package/dist/writers/helpers.d.ts +17 -0
- package/dist/writers/helpers.js +75 -0
- package/dist/writers/omp.d.ts +3 -0
- package/dist/writers/omp.js +7 -0
- package/dist/writers/opencode.d.ts +3 -0
- package/dist/writers/opencode.js +65 -0
- package/dist/writers/pi.d.ts +3 -0
- package/dist/writers/pi.js +45 -0
- package/docs/cline-native-format.md +43 -0
- package/docs/copilot-native-format.md +78 -0
- package/docs/cursor-native-format.md +103 -0
- package/docs/deepagents-native-format.md +98 -0
- package/docs/evidence-api.md +170 -0
- package/docs/fx-native-format.md +87 -0
- package/docs/harness-parity-bootstrap.md +78 -0
- package/docs/harness-support-parity.md +107 -0
- package/docs/inspirations.md +103 -0
- package/docs/ledger-migration.md +141 -0
- package/docs/pi-compaction-context.md +75 -0
- package/docs/pi-provider-context.md +76 -0
- package/docs/research/2026-10-06-huihua-and-oar.md +176 -0
- package/docs/research/ledger/README.md +96 -0
- package/docs/research/ledger/confer/2026-09-13-round-10/README.md +61 -0
- package/docs/research/ledger/confer/2026-09-13-round-2/cass.md +72 -0
- package/docs/research/ledger/confer/2026-09-13-round-3/README.md +70 -0
- package/docs/research/ledger/confer/2026-09-13-round-5/README.md +61 -0
- package/docs/research/ledger/confer/2026-09-13-round-6/README.md +68 -0
- package/docs/research/ledger/confer/2026-09-13-round-7/README.md +64 -0
- package/docs/research/ledger/confer/2026-09-13-round-9/README.md +59 -0
- package/docs/research/ledger/confer/2026-09-14-wake/wake.md +146 -0
- package/docs/research/ledger/docs/inspirations.md +320 -0
- package/docs/research/ledger/docs/pr2-pr6-integration.md +53 -0
- package/docs/research/ledger/docs/research/2026-09-07-confer-inspirations.md +163 -0
- package/docs/research/ledger/docs/research/2026-09-07-letta-continuous-service.md +104 -0
- package/docs/research/ledger/docs/research/2026-09-08-claude-history-retrieval.md +117 -0
- package/docs/research/ledger/docs/research/2026-09-09-agentsview-native-discovery.md +87 -0
- package/docs/research/ledger/docs/research/2026-09-09-daemon-observation.md +231 -0
- package/docs/research/ledger/docs/research/2026-09-09-discovery-pagination.md +114 -0
- package/docs/research/ledger/docs/research/2026-09-09-harness-transcript-contract.md +145 -0
- package/docs/research/ledger/docs/research/2026-09-09-native-history-discovery.md +180 -0
- package/docs/research/ledger/docs/research/2026-09-09-native-history-implementation.md +137 -0
- package/docs/research/ledger/docs/research/2026-09-09-other-inspirations.md +279 -0
- package/docs/research/ledger/docs/research/2026-09-09-trajectory-and-letta-code.md +128 -0
- package/docs/research/ledger/docs/research/2026-09-13-diagnostics-coverage.md +116 -0
- package/docs/research/ledger/docs/research/2026-09-13-fts-rebuild.md +83 -0
- package/docs/research/ledger/docs/research/2026-09-13-read-path-split.md +74 -0
- package/docs/research/ledger/docs/research/2026-09-13-runtime-rollout.md +102 -0
- package/docs/research/ledger/docs/research/2026-09-13-scan-outcomes.md +80 -0
- package/docs/research/ledger/docs/research/2026-09-13-search-quality.md +105 -0
- package/docs/research/ledger/docs/research/2026-09-13-source-coverage.md +64 -0
- package/docs/research/ledger/docs/research/2026-09-13-sync-wall-budget.md +82 -0
- package/docs/research/ledger/docs/research/2026-09-13-typed-read-gate.md +81 -0
- package/docs/research/ledger/docs/research/2026-09-13-writer-lease.md +84 -0
- package/docs/research/ledger/docs/research/2026-09-16-c1-copy-fallback.md +227 -0
- package/docs/research/ledger/docs/research/2026-09-16-c10-kimi.md +414 -0
- package/docs/research/ledger/docs/research/2026-09-16-c2-derive-counter.md +216 -0
- package/docs/research/ledger/docs/research/2026-09-16-c3-echo-filter.md +305 -0
- package/docs/research/ledger/docs/research/2026-09-16-c4-title-index.md +349 -0
- package/docs/research/ledger/docs/research/2026-09-16-c5-trigram.md +268 -0
- package/docs/research/ledger/docs/research/2026-09-16-c6-recency-prior.md +438 -0
- package/docs/research/ledger/docs/research/2026-09-16-c7-tombstones.md +467 -0
- package/docs/research/ledger/docs/research/2026-09-16-c8-remote-truth.md +390 -0
- package/docs/research/ledger/docs/research/2026-09-16-c9-grok.md +380 -0
- package/docs/research/ledger/docs/research/2026-09-16-wake-port-program.md +38 -0
- package/docs/research/ledger/docs/research/2026-09-17-cursor-blobstore.md +157 -0
- package/docs/research/ledger/manifest.json +540 -0
- package/legacy/NOTICE.md +50 -0
- package/legacy/bin/ledger-core.ts +7 -0
- package/legacy/bin/ledger.ts +7 -0
- package/legacy/bin/traj-bridge.ts +7 -0
- package/legacy/scripts/import-claude-history.ts +130 -0
- package/legacy/scripts/native-history-smoke.ts +52 -0
- package/legacy/scripts/observe-daemon.ts +570 -0
- package/legacy/scripts/retitle-sessions.md +19 -0
- package/legacy/scripts/verify-runtime-rollout.ts +151 -0
- package/legacy/src/canonical.ts +108 -0
- package/legacy/src/cli.ts +1293 -0
- package/legacy/src/db.ts +2254 -0
- package/legacy/src/diagnostics.ts +87 -0
- package/legacy/src/discovery.ts +56 -0
- package/legacy/src/embeddings.ts +382 -0
- package/legacy/src/filter.ts +602 -0
- package/legacy/src/gemini_embeddings.ts +103 -0
- package/legacy/src/identity.ts +9 -0
- package/legacy/src/index.ts +10 -0
- package/legacy/src/ingest.ts +791 -0
- package/legacy/src/managed.ts +178 -0
- package/legacy/src/native/common.ts +68 -0
- package/legacy/src/native/cursor.ts +647 -0
- package/legacy/src/native/grok.ts +540 -0
- package/legacy/src/native/kimi.ts +706 -0
- package/legacy/src/native/opencode.ts +470 -0
- package/legacy/src/native/sqlite-ro.ts +281 -0
- package/legacy/src/native.ts +97 -0
- package/legacy/src/normalizer.ts +457 -0
- package/legacy/src/parentage.ts +562 -0
- package/legacy/src/pressure.ts +162 -0
- package/legacy/src/private-storage.ts +74 -0
- package/legacy/src/reader.ts +1255 -0
- package/legacy/src/scanner.ts +591 -0
- package/legacy/src/schema-gate.ts +263 -0
- package/legacy/src/schema.ts +829 -0
- package/legacy/src/semantic-top-k.ts +71 -0
- package/legacy/src/session-hash.ts +49 -0
- package/legacy/src/store-status.ts +218 -0
- package/legacy/src/sync-preparation.ts +45 -0
- package/legacy/src/sync-truth.ts +843 -0
- package/legacy/src/sync.ts +1233 -0
- package/legacy/src/types.ts +436 -0
- package/legacy/src/vocab.txt +30522 -0
- package/legacy/src/watch.ts +673 -0
- package/legacy/src/writer-lease.ts +310 -0
- package/ops/README.md +150 -0
- package/ops/install-cli.sh +63 -0
- package/ops/install-native.sh +35 -0
- package/ops/install-release.sh +49 -0
- package/ops/linux/install-systemd-user.sh +103 -0
- package/ops/macos/install-launchd.sh +72 -0
- package/ops/sync-settings.sh +37 -0
- package/package.json +21 -5
- package/skills/trajectories/SKILL.md +138 -0
- package/skills/trajectories/scripts/trajectories_recent.sh +26 -0
- package/skills/trajectories/scripts/trajectories_search.sh +26 -0
- package/index.js +0 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# C4: the title index and title-first ranking, 2026-09-16
|
|
2
|
+
|
|
3
|
+
## Outcome
|
|
4
|
+
|
|
5
|
+
A session can be found by its name. `titles_fts` is a second FTS5 index beside
|
|
6
|
+
`records_fts` — one row per session whose title the store holds — and a lexical
|
|
7
|
+
query now runs two lanes: the title lane first, then the record lane, with both
|
|
8
|
+
sharing `--limit`. A title hit is *session-level* and says so: `match_type:
|
|
9
|
+
"title"`, `record_id: null` (no record is fabricated for it, Wake's rule),
|
|
10
|
+
`record_type: "title"`, the whole title as the snippet with the matched phrase in
|
|
11
|
+
`«»`, and the session's `started_at` as its timestamp. The lane rides every
|
|
12
|
+
surface — human output (`Role: title`), `--json`, robot v1 and v2 — because
|
|
13
|
+
`--output-version` selects formatting, never lanes.
|
|
14
|
+
|
|
15
|
+
The query the lane exists for, on a byte-consistent copy of the production store:
|
|
16
|
+
`"Hermes Feishu Thread Reply Behavior"` (a session's own name, from a harness
|
|
17
|
+
that titles its sessions) went from **zero genuine matches** — the zero-hit
|
|
18
|
+
widening path substituted five `implicit_wildcard` records, none of them that
|
|
19
|
+
session — to **rank 1**, one `title` hit naming it. A common word (`Feishu`) now
|
|
20
|
+
ranks the sessions that carry it in their *name* above the records that merely
|
|
21
|
+
mention it, and a body-only query (`wal_autocheckpoint`) returns the same top
|
|
22
|
+
hits it did before: the record lane is untouched.
|
|
23
|
+
|
|
24
|
+
The index is derived state on the C2 counter, so the change ships as
|
|
25
|
+
`FTS_FORMAT` `"2"` → `"3"`: every store written before it re-derives itself once
|
|
26
|
+
on its next writer pass, in the *same* transaction that re-projects
|
|
27
|
+
`records_fts`. On the production copy (1.125 GB, 229,103 records, 2,609
|
|
28
|
+
sessions) that one-shot cost **15.8 s** — the `records_fts` re-projection C2/C3
|
|
29
|
+
already measured — of which **9.4 ms** was the title rebuild and **7.3 ms** the
|
|
30
|
+
title gate. The store's own numbers make the case concrete: **2,609 of 2,609
|
|
31
|
+
sessions carry a title** (277,385 characters, 859 distinct titles) and **2,348 of
|
|
32
|
+
them (90%) are a prefix of one of their own user records** — the title *is* the
|
|
33
|
+
first instruction, which the record index could only ever find as text inside
|
|
34
|
+
that record, never as the session's name.
|
|
35
|
+
|
|
36
|
+
## The mechanism (five pieces)
|
|
37
|
+
|
|
38
|
+
**1. A standalone index, not a column on `records_fts`.**
|
|
39
|
+
`titles_fts(session_id UNINDEXED, title, tokenize="porter unicode61")` — one row
|
|
40
|
+
per session with a non-empty title; `NULL` and `''` are the same non-title.
|
|
41
|
+
A title belongs to the session, has no record id to point at, and is derived from
|
|
42
|
+
harness metadata or the first user message, so folding it into the record index
|
|
43
|
+
would mean either a fabricated row (inventing a record no adapter wrote) or the
|
|
44
|
+
same title indexed once per record of that session. Wake splits the same way and
|
|
45
|
+
for the same reason (`db.rs#L126-L130`: `titles_fts(key UNINDEXED, title)`, the
|
|
46
|
+
key carried only to find and delete the row — "a title is not a transcription
|
|
47
|
+
message and has no `seq` to pair with"). Our tokenizer is the record index's, not
|
|
48
|
+
Wake's trigram: this is the trigram-free first pass, and the tokenizer decision
|
|
49
|
+
is C5's (docs/research/2026-09-16-wake-port-program.md).
|
|
50
|
+
|
|
51
|
+
**2. Maintenance at every write point, as triggers on `sessions`.**
|
|
52
|
+
The index is a projection of one canonical column, so the rule that keeps it
|
|
53
|
+
equal to that column is a predicate rather than a decision — the case where a
|
|
54
|
+
trigger is right and a call site is not. Three triggers
|
|
55
|
+
(`trg_sessions_titles_insert` / `_update` / `_delete`, in `CREATE_TABLES_SQL`, so
|
|
56
|
+
every store gets them on its next writer open) cover `saveSession`'s three paths,
|
|
57
|
+
`appendSessionRecords`, the staged promotion, and any raw connection that
|
|
58
|
+
bypasses the helpers — including one running with foreign keys off. The update
|
|
59
|
+
trigger fires only when the title actually changed (`IS NOT`, so an absent title
|
|
60
|
+
is compared too) and re-indexes that session's one row by delete-then-insert,
|
|
61
|
+
which is Wake's shape at its single write point (`db.rs#L1968-L1976`: `DELETE
|
|
62
|
+
FROM titles_fts WHERE key = ?` then an insert when the title is not empty). A
|
|
63
|
+
deleted session's title row goes with it: no foreign key can enforce that for a
|
|
64
|
+
virtual table, which is the same reason `sessions` already carries
|
|
65
|
+
`trg_sessions_diagnostics_delete`.
|
|
66
|
+
|
|
67
|
+
**3. Re-derivation in the counter's one transaction, with its own gate.**
|
|
68
|
+
`reindexFts()` (renamed from `reindexRecordsFts`: the counter names the *rules*,
|
|
69
|
+
and one rule version is one derivation) now rebuilds both indexes — drop,
|
|
70
|
+
recreate, re-project `records_fts` from `records`, recompute and subtract the
|
|
71
|
+
echo set, repopulate `titles_fts` from `sessions` — and gates both before it
|
|
72
|
+
clears the flag. The title gate is exact parity
|
|
73
|
+
(`COUNT(titles_fts) == COUNT(sessions WHERE title != '')`, against the
|
|
74
|
+
repopulation's own predicate, kept as one exported constant), an orphan check
|
|
75
|
+
(every indexed row still names a live session — the one thing a virtual table
|
|
76
|
+
cannot enforce for itself), and FTS5's `integrity-check`. Because the triggers
|
|
77
|
+
live on `sessions`, dropping and recreating the index leaves them in place. A
|
|
78
|
+
store whose counter matches but whose `titles_fts` is missing was not derived by
|
|
79
|
+
these rules, so `DERIVE_COUNTERS[0].outputs` names the new table and the flag is
|
|
80
|
+
raised exactly as it is for a moved counter.
|
|
81
|
+
|
|
82
|
+
**4. Two lanes, one limit.**
|
|
83
|
+
`queryLexical` probes for the index (a store whose derivation is pending has
|
|
84
|
+
none), runs the title lane first, and emits its hits ahead of the record lane's.
|
|
85
|
+
The title lane is bounded by the caller's limit and the record lane gets what is
|
|
86
|
+
left, so `totalHits` stays what `meta.count_precision` describes — a title hit
|
|
87
|
+
displaces a record hit at the boundary rather than extending the result past the
|
|
88
|
+
limit. Ordering is `bm25(titles_fts) ASC, s.started_at DESC`: the same shape the
|
|
89
|
+
record lane uses. Wake multiplies this lane's bm25 by a time decay
|
|
90
|
+
(`db.rs#L1676-L1700`, `RECENCY_BOOST` at `#L1552`); the decay is C6's cycle, so
|
|
91
|
+
this lane keeps the ordering every other query in the store has. Widening stays a
|
|
92
|
+
*whole-query* zero-hit recovery: it runs only when both lanes found nothing, and
|
|
93
|
+
it widens the record lane (a title is matched by its words or not at all).
|
|
94
|
+
|
|
95
|
+
**5. The lane never invents a record, and the echo filter has no opinion about
|
|
96
|
+
titles.**
|
|
97
|
+
`record_id: null`, `record_type: "title"`, `content: null` — Wake's `seq: 0` /
|
|
98
|
+
`role: "title"` rule (`db.rs#L1749-L1757`: the title hit jumps to the session's
|
|
99
|
+
start because there is no transcription row to jump to, and no `seq` is
|
|
100
|
+
faked). In the type system this is a union, not a nullable field
|
|
101
|
+
(`RecordSearchHit | TitleSearchHit`), so a consumer that needs a record is forced
|
|
102
|
+
to narrow rather than silently reading a null.
|
|
103
|
+
|
|
104
|
+
Titles stay searchable for **every** session, including sessions whose records
|
|
105
|
+
the self-referential echo filter keeps out of `records_fts` (C3): a title is
|
|
106
|
+
provenance metadata — who asked what — not echo content, and the filter is a
|
|
107
|
+
record-level rule. An agent's own lookup sessions remain findable by name while
|
|
108
|
+
the lookups themselves stay unindexed; `tests/title-index.test.ts` pins exactly
|
|
109
|
+
that pair.
|
|
110
|
+
|
|
111
|
+
## Files
|
|
112
|
+
|
|
113
|
+
- `packages/core/src/schema.ts` — `FTS_FORMAT = "3"`; `TITLES_FTS_DDL`,
|
|
114
|
+
`TITLES_FTS_REPOPULATE_SQL`, `TITLES_FTS_REBUILD_SQL`,
|
|
115
|
+
`TITLES_FTS_TRIGGERS_SQL`, `INDEXABLE_TITLE_SQL` (the one indexability
|
|
116
|
+
predicate: repopulation, triggers, and gate all apply `title != ''`);
|
|
117
|
+
`DERIVE_COUNTERS[0].outputs` grows `titles_fts`; `CREATE_TABLES_SQL` creates the
|
|
118
|
+
table and its three triggers.
|
|
119
|
+
- `packages/core/src/db.ts` — `rebuildTitlesFts()` (rebuild + gate) beside
|
|
120
|
+
`rebuildRecordsFts()`; `reindexFts()` runs both then clears the flag;
|
|
121
|
+
`configureFtsAutomerge` pins the same automerge policy for both indexes;
|
|
122
|
+
`verifyTitlesFtsRebuild` is the exported gate (parity, orphan, integrity),
|
|
123
|
+
module scope so tests can drive it against a doctored store.
|
|
124
|
+
- `packages/core/src/types.ts` — `SearchHit` becomes
|
|
125
|
+
`RecordSearchHit | TitleSearchHit`; `RecordMatchType` and `SearchMatchType`
|
|
126
|
+
(`… | "title"`); `TitleSearchHit` documents the no-fabrication rule.
|
|
127
|
+
- `packages/core/src/reader.ts` — the title lane (`titlePrepass`, presence probe,
|
|
128
|
+
`highlight(titles_fts, 1, …)`), the two-lane budget in `queryLexical`, the
|
|
129
|
+
title pre-pass ahead of RRF in `queryHybrid`; `escapedTermExpression` is shared
|
|
130
|
+
by both lanes so a fallback expression cannot drift between them;
|
|
131
|
+
`sessionsCount` is now computed once from the returned hits instead of being
|
|
132
|
+
threaded through each engine.
|
|
133
|
+
- `packages/core/src/sync.ts`, `cli.ts`, `watch.ts` — the renamed one-shot call
|
|
134
|
+
sites, and the re-derivation prose now names both indexes.
|
|
135
|
+
- `packages/core/tests/title-index.test.ts` (new) — the index's parity and repair
|
|
136
|
+
(including the two gate refusals), title-first emission with the session-level
|
|
137
|
+
hit's fields, the shared limit, the source filter and the recency tiebreak, the
|
|
138
|
+
echo interplay, the rename/append/delete and staged-promotion maintenance, the
|
|
139
|
+
format bump (pending state tolerated → one-shot → parity → exactly once), the
|
|
140
|
+
hybrid pre-pass with a real vector leg, and the robot/human surfaces through
|
|
141
|
+
the CLI.
|
|
142
|
+
- Re-pinned to the new contract (each because the assertion's subject is the
|
|
143
|
+
*record* lane, which now has a second lane beside it): `derive-counters`,
|
|
144
|
+
`ingest-transaction`, `ingest-staging`, `sync`, `watch`, `e2e` (record-lane
|
|
145
|
+
counts), `cli-skill-wrappers` (the stream now carries the title hit),
|
|
146
|
+
`api.test.ts` (the robot hit shape holds for either lane), `search-quality`
|
|
147
|
+
(the fusion's provenance filter narrows on `record_id`).
|
|
148
|
+
- `docs/api-surface.md` — §1.4.4's lane contract (fields, budget, v1/v2, the
|
|
149
|
+
widening interaction), §3.1's `titles_fts` table, the `"3"` counter text, the
|
|
150
|
+
`REQUIRED_READ_SCHEMA` note, the renamed method, and a C4 ADR.
|
|
151
|
+
|
|
152
|
+
## Ported mechanics (Wake MIT, `iAmCorey/Wake@d9f702b6` tag v0.6.5 — mechanics with attribution, no code copied)
|
|
153
|
+
|
|
154
|
+
Pin: `d9f702b6df0e85` (`Release v0.6.5`), `crates/wake-core/src/db.rs`, MIT
|
|
155
|
+
License © 2026 Corey Chiu.
|
|
156
|
+
|
|
157
|
+
| Ledger | Wake pin | What was kept |
|
|
158
|
+
| --- | --- | --- |
|
|
159
|
+
| A standalone title index keyed by session, the key UNINDEXED | `db.rs#L126-L130`, and the comment above it (`#L124-L125`) | a title is not a record and has no `seq` to pair with, so it gets its own table rather than a fake row; the key is carried only to find and delete the row |
|
|
160
|
+
| Title hits emitted ahead of body hits, at session level | `db.rs#L1676-L1700` (the title query, `ORDER BY bm25(titles_fts) * RECENCY_BOOST LIMIT ?`) | the lane runs first and its hits precede the record lane's; the query is session-level and joins `sessions` for the metadata the hit reports |
|
|
161
|
+
| `seq: 0`, `role: "title"`, never a fabricated `seq` | `db.rs#L1749-L1757` | our analogue is `record_id: null` + `record_type: "title"`: the hit jumps to the session's start because there is no record to jump to, and none is invented |
|
|
162
|
+
| One write point keeps the index equal to the title column | `db.rs#L1968-L1976` (`upsert_session`: delete by key, insert when non-empty) | we make the write point total instead of single by expressing it as triggers on `sessions`, so every path — and every raw connection — maintains the pair |
|
|
163
|
+
| A rule change bumps the index-format counter and the old store flags itself | `db.rs#L133-L136` (`FTS_FORMAT`, doc: "改了派生规则就换个值"), `#L176-L196` (compare at open, flag when sessions existed) | the C2 machinery, consumed a second time: `FTS_FORMAT` `"2"` → `"3"`, exactly one re-derivation per store |
|
|
164
|
+
| The derivation is the *index's* whole re-projection | `db.rs#L212-L223` (an empty index beside non-empty sessions is repopulated in one `INSERT … SELECT`), `tests/db_roundtrip.rs#L1015-L1038` (`title_index_is_backfilled_for_older_databases` simulates the table just created and asserts the titles are searchable again) | the same `INSERT … SELECT` from the sessions table is what our one-shot runs — but as the counter's derivation rather than an open-time backfill (see deviations) |
|
|
165
|
+
|
|
166
|
+
## Deviations from the scout design (each with its reason)
|
|
167
|
+
|
|
168
|
+
- **Triggers instead of maintenance calls in the upsert paths.** The scout
|
|
169
|
+
sketched "`saveSession`/append paths/`commitStagedSession` maintain `titles_fts`
|
|
170
|
+
rows in the same transaction (C3's `records_echo` shows the pattern)". The tree
|
|
171
|
+
has both patterns — `records_echo` is written by the ingest decision that skips
|
|
172
|
+
the FTS row, and `trg_sessions_diagnostics_delete` exists because a session's
|
|
173
|
+
rows must not outlive it for *any* writer. Titles have no decision to make
|
|
174
|
+
(the rule is a column predicate) and three write paths plus one delete path to
|
|
175
|
+
cover, so the total form is the trigger set: it leaves `saveSession`,
|
|
176
|
+
`appendSessionRecords`, and `promoteStagedIngest` untouched, cannot be missed by
|
|
177
|
+
a future path, and makes an orphaned title row impossible rather than merely
|
|
178
|
+
detected. The gate still checks for orphans, because a store can be written
|
|
179
|
+
with the triggers dropped.
|
|
180
|
+
- **The lane is shared with the record lane's limit.** Wake emits title hits up to
|
|
181
|
+
`limit` *and* body hits up to `limit` (two bounded lists, up to 2× the caller's
|
|
182
|
+
limit). Our envelope's documented invariant is that `totalHits` is bounded by
|
|
183
|
+
`--limit`, so the title hits come out of that budget and the record lane fills
|
|
184
|
+
what is left. The cost is real and deliberate: at the boundary a title hit
|
|
185
|
+
displaces a record hit. The alternative — up to 2× the limit — would break
|
|
186
|
+
`count_precision`'s single meaning for every consumer.
|
|
187
|
+
- **No session-level dedup, in either direction.** A session matched by title
|
|
188
|
+
*and* body appears once per lane (Wake's shape), and its record hits are not
|
|
189
|
+
suppressed by its title hit. Suppressing them would hide the record evidence
|
|
190
|
+
behind a 120-character name — the title is a prefix of the first message for
|
|
191
|
+
90% of the store's sessions, so the suppressed row is usually the text the user
|
|
192
|
+
was looking for — and it would make the record lane's answer depend on whether a
|
|
193
|
+
title happened to match. `search-quality.test.ts` pins the fusion's provenance;
|
|
194
|
+
`title-index.test.ts` pins that both rows survive.
|
|
195
|
+
- **No open-time backfill of an empty index (Wake's `#L212-L223`).** Wake tests
|
|
196
|
+
"the table is empty and sessions exist" at every open because an empty table is
|
|
197
|
+
its only signal that the feature is new. Our counter already carries that signal
|
|
198
|
+
and owns the derivation: `"2"` ≠ `"3"` raises `fts_reindex`, and the pass that
|
|
199
|
+
holds the lease re-derives in the transaction that clears the flag. An
|
|
200
|
+
open-time backfill would be a second derivation point outside the pass's
|
|
201
|
+
accounting (it would not appear in `derive_ms`), which is exactly the
|
|
202
|
+
first-class switch C2 shipped. The visible difference is one pass: between the
|
|
203
|
+
writer open and the pass, reads serve body hits and no title hits — the same
|
|
204
|
+
window C3 documented for `records_echo`, and the same state `status.echo_filtered
|
|
205
|
+
→ null` covers ("unknown, not zero").
|
|
206
|
+
- **The title gate's parity is exact rather than subtracted.** `records_fts`
|
|
207
|
+
gates on `records − excluded == indexed`; the title index has no excluded set,
|
|
208
|
+
so it gates on `COUNT(titles_fts) == COUNT(sessions WHERE title != '')` plus the
|
|
209
|
+
orphan check. Both terms use the same exported predicate the repopulation and
|
|
210
|
+
the triggers apply.
|
|
211
|
+
- **The title lane is skipped, not refused, when the index is missing.** The read
|
|
212
|
+
contract deliberately requires no index *presence* (§3.1 `REQUIRED_READ_SCHEMA`),
|
|
213
|
+
so the lane probes `sqlite_master` once per query and answers "no title hits"
|
|
214
|
+
for a store whose derivation is pending, instead of refusing a store the record
|
|
215
|
+
lane can serve.
|
|
216
|
+
- **`reindexRecordsFts` → `reindexFts`.** The one-shot derives both indexes now;
|
|
217
|
+
keeping the old name would have made the method's name a lie at the exact place
|
|
218
|
+
a reader looks for the contract. The flag and the counter keep their names
|
|
219
|
+
(`fts_reindex`, `fts_format`) because they were never records-specific.
|
|
220
|
+
|
|
221
|
+
## Audit against the scout design
|
|
222
|
+
|
|
223
|
+
| Contract item | State |
|
|
224
|
+
| --- | --- |
|
|
225
|
+
| Standalone `titles_fts(session_id UNINDEXED, title, porter unicode61)` | as designed (trigram-free first pass; C5 owns the tokenizer) |
|
|
226
|
+
| `FTS_FORMAT` → `"3"`; `DERIVE_COUNTERS` outputs grow to `[records_fts, records_echo, titles_fts]` | as designed; a matched counter with the table missing re-derives |
|
|
227
|
+
| Rebuild populates from the sessions' title source, one-shot | as designed, from `sessions.title` — the column the normalizer writes and the reader projects, so there is exactly one title implementation in the tree (`normalizer.ts` derives it; nothing recomputes it at query time) |
|
|
228
|
+
| Parity gate: `COUNT(titles_fts)` == sessions with a non-empty title + orphan check, beside the existing gates | as designed, plus FTS5 `integrity-check` |
|
|
229
|
+
| Lexical: titles first (bm25 ASC, recency tiebreak), ahead of body hits, within the limit | as designed |
|
|
230
|
+
| Body hits exclude sessions already hit by title? | decided: **no** — Wake emits both, and record-level hits were never session-deduped (see deviations) |
|
|
231
|
+
| Hybrid: title pre-pass as session-level hits preceding RRF fusion | as designed (title hits do not enter the fusion: they carry no record id to key it) |
|
|
232
|
+
| Semantic lane untouched | as designed — except its lexical *fallback*, which is the lexical engine and therefore carries the lane |
|
|
233
|
+
| `SearchMatchType += "title"`; `record_id: null`; `record_type: "title"`; snippet = the matched title; `started_at` = session start | as designed; the type is a discriminated union so a consumer must narrow |
|
|
234
|
+
| Robot v2 + human CLI both surface it | as designed; v1 too, because `--output-version` is documented as formatting-only |
|
|
235
|
+
| Echo: all session titles stay searchable | as designed and documented as a decision: a title is provenance metadata, not echo content |
|
|
236
|
+
| Maintenance in the same transaction | as designed, via triggers on `sessions` (see deviations) |
|
|
237
|
+
| Reader/status tolerate a pending derivation | as designed (probe per query; `title-index.test.ts` pins the pending read, the empty index, and `echo_filtered: null`) |
|
|
238
|
+
|
|
239
|
+
## Measurements
|
|
240
|
+
|
|
241
|
+
Host: Apple M1, macOS 25.6, APFS. Fixture: a byte-consistent copy of the
|
|
242
|
+
production store (`sqlite3 "file:…?mode=ro" ".backup /tmp/ledger-c4/ledger.sqlite"`;
|
|
243
|
+
1,125,158,912 bytes, 229,103 records, 2,609 sessions, 0 embeddings). The
|
|
244
|
+
production store is never opened for writing — the read-only numbers below come
|
|
245
|
+
from `?mode=ro` queries and the trajectory readers used for the search
|
|
246
|
+
observations; the measurement harness (`/tmp/ledger-c4`) was deleted afterwards.
|
|
247
|
+
|
|
248
|
+
The tree's own state proved the upgrade path once for real before this audit: the
|
|
249
|
+
production store already carried `fts_format = 3`, a `titles_fts` of **2,609**
|
|
250
|
+
rows, and the three maintenance triggers (a scheduled pass had run against this
|
|
251
|
+
working tree), and its parity holds — 2,609 indexed titles for 2,609 titled
|
|
252
|
+
sessions. The "before" state below was therefore reconstructed on the copy, not
|
|
253
|
+
observed on the live store.
|
|
254
|
+
|
|
255
|
+
**OBSERVED**
|
|
256
|
+
|
|
257
|
+
| measurement | value |
|
|
258
|
+
| --- | --- |
|
|
259
|
+
| production store, read-only | 2,609 sessions, **2,609 with a non-empty title** (277,385 characters, 859 distinct titles); `fts_format = 3`; `titles_fts` = 2,609 rows; `trg_sessions_titles_{insert,update,delete}` present |
|
|
260
|
+
| titles that are a prefix of one of their own user records | **2,348 of 2,609 (90.0%)** — the title *is* the first instruction for the common case; 261 titles come from elsewhere (harness metadata, or a first message that is not a `user` record) |
|
|
261
|
+
| copy, canonical side | 229,103 records, 228,903 indexed = records − 200 echo exclusions |
|
|
262
|
+
| reconstructed pre-C4 state on the copy | `fts_format = '2'`, `DROP TABLE titles_fts`, flag cleared: the C3 shape |
|
|
263
|
+
| search before — the session's own name, `"Hermes Feishu Thread Reply Behavior"` | **0 genuine matches**; `totalHits: 5`, every hit `implicit_wildcard` (the zero-hit widening path substituted prefix matches from other sessions), `widened.applied` set |
|
|
264
|
+
| search before — `Feishu` (a word in that title) | 5 hits, all `lexical` record hits; the session that is *named* Feishu is not the answer |
|
|
265
|
+
| search before — `wal_autocheckpoint` (body-only) | 5 hits across 2 sessions |
|
|
266
|
+
| writer open of the pre-C4 copy | **3 ms**; stamped `fts_format = 3`, raised `fts_reindex = 1`, recreated `titles_fts` empty (0 rows); reads still served 5 hits with no title lane (pending is not a refusal) |
|
|
267
|
+
| one-shot `reindexFts()` on the copy | **15,835 ms**; `titles_fts` 2,609 = titled sessions 2,609; `records_fts` 228,903 = records − excluded; flag cleared; `FTS_FORMAT` stamped; `records_fts` content unchanged (228,903 before and after) |
|
|
268
|
+
| title phase, timed separately (warm) | rebuild (drop + create + `INSERT … SELECT` 2,609 rows) **9.4 ms**; gate (parity + orphan + `integrity-check`) **7.3 ms** — 0.1% of the one-shot, whose cost is the `records_fts` re-projection |
|
|
269
|
+
| search after — the session's own name | **`totalHits: 1`, `sessions_count: 1`, rank 1**: one `match_type: "title"` hit naming `hermes:20260622_161708_b6f70dc8`, snippet `«Hermes Feishu Thread Reply Behavior»`; `widened: null` (the lane's hit is a hit, so the widening path is not entered) |
|
|
270
|
+
| search after — `Feishu` | 5 hits; the first three are `title` hits (the sessions named after Feishu, best first), the record hits follow |
|
|
271
|
+
| search after — `wal_autocheckpoint` (body-only) | **the same 5 hits across the same 2 sessions, in the same order** as before the derivation |
|
|
272
|
+
| robot v2, copy | `{"type":"search_meta", …, "total_hits":1, "sessions_count":1, "widened":null}` then `{"session_id":"hermes:20260622_161708_b6f70dc8","source":"hermes","title":"Hermes Feishu Thread Reply Behavior","timestamp":"2026-06-22T08:17:06.724Z","score":-10.541302855850118,"snippet":"«Hermes Feishu Thread Reply Behavior»","match_type":"title", …}` — no `record_id` field (the v2 stream has never carried one, so nothing is fabricated), `timestamp` is the session's start |
|
|
273
|
+
| human output, copy | `[1] [hermes] Session: hermes:20260622_161708_b6f70dc8 (2026-06-22T08:17:06.724Z)` / `Title: …` / `Role: title` / `Match: «Hermes Feishu Thread Reply Behavior»` |
|
|
274
|
+
| `status --robot`, copy | `echo_filtered: 200` (the derivation under this build's rules completed); in the *pending* window the same field is `null`, pinned by `title-index.test.ts` rather than measured here |
|
|
275
|
+
| gate on the derived copy | passed (parity 2,609 == 2,609, no orphan, `integrity-check` clean) |
|
|
276
|
+
| second `reindexFts()` | `false` — exactly once per store |
|
|
277
|
+
| existing suite | **579 pass / 0 fail across 40 files** (568 at HEAD; `tests/title-index.test.ts` adds 11), `bun run typecheck` clean, `bun run build` clean |
|
|
278
|
+
|
|
279
|
+
**INFERRED** (not directly measured, listed so reviewers can attack them)
|
|
280
|
+
|
|
281
|
+
- The 15.8 s one-shot is the *warm* `records_fts` re-projection band C2/C3
|
|
282
|
+
measured (10.0–33.3 s across runs and hosts), plus ~17 ms of title work. Nothing
|
|
283
|
+
in this cycle's measurement separates the title phase from the record phase's
|
|
284
|
+
page-cache state, so a cold first pass on a user's machine should be expected in
|
|
285
|
+
the C2 band, title work included.
|
|
286
|
+
- The 90% title/record overlap is measured on *this* store, whose titles derive
|
|
287
|
+
from first user messages for `pi`, `codex`, and `cursor` sessions. A store of
|
|
288
|
+
renamed or harness-titled sessions (claude-code summaries, Wake-style browser
|
|
289
|
+
titles) would show a different ratio, and the lane's value grows as that ratio
|
|
290
|
+
falls.
|
|
291
|
+
- The budget interaction is measured only at the boundary the tests construct
|
|
292
|
+
(three titled sessions against a limit of two): the note's claim that a title
|
|
293
|
+
hit displaces a record hit at the limit is arithmetic from the implementation
|
|
294
|
+
(`limit − titles.length`), not a production observation.
|
|
295
|
+
- The orphan branch of the gate is unreachable through a store that was merely
|
|
296
|
+
written (the triggers maintain the pair, and the rebuild drops the table whole),
|
|
297
|
+
so it is exercised only by the test that doctors the index directly.
|
|
298
|
+
- Wake's trigram tokenizer would change this lane's recall (mid-token substrings,
|
|
299
|
+
CJK) and its index size; C5 owns that measurement and will re-derive under a
|
|
300
|
+
fourth counter bump. Nothing here depends on the choice beyond the DDL string.
|
|
301
|
+
- A raw connection that drops `titles_fts` by hand while the triggers exist makes
|
|
302
|
+
session writes fail until the next writer open recreates the table
|
|
303
|
+
(`CREATE_TABLES_SQL` is idempotent and runs on every writer open). This is the
|
|
304
|
+
same class of coupling `trg_sessions_diagnostics_delete` already has with
|
|
305
|
+
`ingest_diagnostics`, and it is not measured in production because nothing does
|
|
306
|
+
it.
|
|
307
|
+
|
|
308
|
+
## Position in the program (C4 row, `docs/research/2026-09-16-wake-port-program.md`)
|
|
309
|
+
|
|
310
|
+
- Failure it fixes: *"Titles render in results but are unsearchable
|
|
311
|
+
(`records_fts` has no title column)."*
|
|
312
|
+
- Open-source case: *"Session-find-by-name is the human's first query; every
|
|
313
|
+
session browser ships it."*
|
|
314
|
+
- Ordering: C4 follows C3 on the same counter (the second bump, the second
|
|
315
|
+
consumer of the marker/one-shot pattern) and precedes C5, which will change the
|
|
316
|
+
tokenizer behind the same DDL family. C6's recency prior will land on top of
|
|
317
|
+
this lane's `ORDER BY` (Wake already multiplies this exact query by its time
|
|
318
|
+
decay, `db.rs#L1685`), and C7's tombstone work inherits the open question this
|
|
319
|
+
cycle's maintenance answers for titles: a delete path that must reach a derived
|
|
320
|
+
index the store's foreign keys cannot.
|
|
321
|
+
|
|
322
|
+
## Boundaries and follow-ups (not in this cycle)
|
|
323
|
+
|
|
324
|
+
1. **Rule changes need the bump.** The indexability predicate (`title != ''`) and
|
|
325
|
+
the lane's shape are the rules `FTS_FORMAT` `"3"` names; editing either without
|
|
326
|
+
a bump leaves every store's title index as-is. The maintenance triggers are
|
|
327
|
+
DDL, so a change to them rides `CREATE_TABLES_SQL` (idempotent, every writer
|
|
328
|
+
open), not the counter.
|
|
329
|
+
2. **A store that records `"3"` with a hand-emptied `titles_fts`** is not detected:
|
|
330
|
+
the counter names the rules, not the rows, and the output table's *presence* is
|
|
331
|
+
all the open-time check reads — the same stance C3 documented for
|
|
332
|
+
`records_echo`. Raising `fts_reindex` by hand (or the next bump) repairs it;
|
|
333
|
+
parity is what would notice.
|
|
334
|
+
3. **Titles are indexed as text, not as names.** The lane matches the same
|
|
335
|
+
tokenized words the record lane does, so a title word the query misspells, or
|
|
336
|
+
a mid-token substring, still misses — C5's tokenizer decision is where that
|
|
337
|
+
changes for both indexes at once.
|
|
338
|
+
4. **No title-lane tuning knobs.** No `--title-limit`, no weight: the lane's cost
|
|
339
|
+
is one extra bounded index query per lexical search (the whole rank-1 title
|
|
340
|
+
query above — probe, title pass, record pass, projection — reported
|
|
341
|
+
`elapsed_ms: 1.471` on the production copy), and its share of the limit is the
|
|
342
|
+
documented rule. A weight or a separate budget is C6's territory (relevance),
|
|
343
|
+
not a hidden default.
|
|
344
|
+
5. **The v1 robot stream carries title hits.** By the documented contract
|
|
345
|
+
`--output-version` selects formatting, never lanes, so v1 consumers that
|
|
346
|
+
assumed "every hit is a record" meet a row whose snippet is the session's name
|
|
347
|
+
(`tests/cli-skill-wrappers.test.ts` pins it). The alternative — filtering the
|
|
348
|
+
lane for v1 — would have made the version select the search algorithm, which
|
|
349
|
+
that contract forbids.
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# C5: the trigram swap, measured and refused, 2026-09-16
|
|
2
|
+
|
|
3
|
+
## Outcome
|
|
4
|
+
|
|
5
|
+
The decision round approved a trigram swap **with binding conditions**, and the
|
|
6
|
+
conditions were measured first, on `/tmp` copies of the production store. They
|
|
7
|
+
fail: nothing in this repo changes, `FTS_FORMAT` stays `"3"`, and both indexes
|
|
8
|
+
stay `tokenize="porter unicode61"`. The program's C5 row is closed as
|
|
9
|
+
*measured, refused* rather than refused on judgement — the capability case is
|
|
10
|
+
real, and it is the cost that refuses it.
|
|
11
|
+
|
|
12
|
+
Every binding condition of the decision round fails on at least one
|
|
13
|
+
measured case, on the store as it stands today (1,134,215,168 bytes, 230,220
|
|
14
|
+
records, 2,612 titled sessions, 198 MB of indexed text):
|
|
15
|
+
|
|
16
|
+
| bar (from the decision round) | threshold | observed | verdict |
|
|
17
|
+
| --- | --- | --- | --- |
|
|
18
|
+
| total store file size after the one-shot re-derivation | ≤ +35% (≤ 1,531 MB) | **1,729,425,408 B = +52.5%** | **FAIL** |
|
|
19
|
+
| FTS shadow index growth, the doc's own basis (`_data` + `_idx`) | ≤ 5.5× | **6.93×** (100,798,464 → 698,441,728 B) | **FAIL** |
|
|
20
|
+
| one-shot re-derivation wall, idle (both indexes, gates included) | ≤ 45 s | **54.3 s / 54.6 s** (two runs) | **FAIL** |
|
|
21
|
+
| identifier-substring query (class 1) | ≤ 15 ms median | 3.2–15.7 ms for rare identifiers, **190–200 ms for a common one** | **FAIL** (class) |
|
|
22
|
+
| degraded short-segment `LIKE` (class 3) | ≤ 50 ms median / ≤ 150 ms p95 | 0.15–1.1 ms when the term matches early, **248–289 ms when the scan is total** | **FAIL** (class) |
|
|
23
|
+
| CJK, 3+ codepoints (class 2) | ≤ 20 ms median | 0.39 ms | pass |
|
|
24
|
+
| natural-language AND (class 4) | ≤ 20 ms median / ≤ 50 ms p95 | 6.8 / 39.3 / 18.5 ms | fail on one of three |
|
|
25
|
+
| #34 common-prefix floor (class 5) | ≤ 25 ms median | **283–456 ms** | **FAIL** |
|
|
26
|
+
|
|
27
|
+
Two findings deserve naming before the tables:
|
|
28
|
+
|
|
29
|
+
- **Trigram does not fix the #34 floor.** The hypothesis was that `"the"`
|
|
30
|
+
becomes a single cheap trigram. It does become a single trigram, and that
|
|
31
|
+
trigram's posting list is 79,122 of 230,220 records — the cost is match-set
|
|
32
|
+
and bm25 bound, exactly the class #34's closing comment named, and the measured
|
|
33
|
+
numbers (390–456 ms) sit at parity with or slightly above the widened porter
|
|
34
|
+
path they were meant to retire (214–418 ms). Substring *recall* is bought; the
|
|
35
|
+
floor stands.
|
|
36
|
+
- **The decision round's own bar table was internally unsatisfiable.** Its
|
|
37
|
+
baseline (811 MB store, ~150 MB shadow, +35% total) allows a shadow of at most
|
|
38
|
+
~434 MB — a 2.9× growth — while the same table allows 5.5× (~825 MB), which is
|
|
39
|
+
a +83% store. Any re-decision should derive both bars from one budget.
|
|
40
|
+
|
|
41
|
+
## The design that was going to ship (kept for a future cycle)
|
|
42
|
+
|
|
43
|
+
The full contract, for the record and for whoever reopens this:
|
|
44
|
+
|
|
45
|
+
- **Both indexes**: `tokenize="trigram case_sensitive 0"` — `records_fts` and
|
|
46
|
+
`titles_fts` together (Wake's line: `db.rs#L63-L67` and `#L126-L130` both use
|
|
47
|
+
it), because the two lanes are handed the same compiled expression and a
|
|
48
|
+
porter title lane beside a trigram record lane would answer differently for
|
|
49
|
+
the same identifier.
|
|
50
|
+
- **`FTS_FORMAT = "4"`** — C2's counter does the migration: the next writer pass
|
|
51
|
+
re-derives both indexes in one transaction, gates unchanged (they are
|
|
52
|
+
count-based), and stamps the new format. Rollback is the same machinery
|
|
53
|
+
downwards: a `"3"` build stamps `"4"` → `"3"` and re-derives back to porter.
|
|
54
|
+
- **Query compilation**: whitespace segments, each quoted as a phrase and
|
|
55
|
+
`AND`-joined (`db.rs#L1587-L1591`); C4's title/record lane split and limit
|
|
56
|
+
budget unchanged.
|
|
57
|
+
- **Whole-query `LIKE` degradation**: any segment shorter than 3 codepoints
|
|
58
|
+
(`Array.from(seg).length < 3`, Wake's `chars().count() < 3` at `db.rs#L1544`)
|
|
59
|
+
sends the whole query for that lane to `LIKE '%…%' ESCAPE '\'` — the record
|
|
60
|
+
lane over `content`/`tool_arguments_json`/`tool_result_json`, the title lane
|
|
61
|
+
over `sessions.title` (Wake degrades titles too, `db.rs#L1706`), with the
|
|
62
|
+
echo filter as an anti-join against `records_echo` because canonical `records`
|
|
63
|
+
*does* hold echo rows (74 of the store's 216 echo records match `'%db%'` — the
|
|
64
|
+
leak would be real).
|
|
65
|
+
- **Disclosed, never swallowed**: the envelope gains `degraded: boolean` +
|
|
66
|
+
`degraded_reason: "short_segment_under_3_chars" | null`, hits carry
|
|
67
|
+
`match_type: "like_degraded"`, and the CLI prints the caveat on stderr for v1
|
|
68
|
+
robot consumers that carry no meta.
|
|
69
|
+
- **Prefix-OR widening retired** for the trigram lanes (substring subsumes
|
|
70
|
+
prefix): `LEDGER_WIDEN_MAX_RECORDS` and the widening pass go away, the
|
|
71
|
+
envelope's `widened` field stays for compatibility and reports
|
|
72
|
+
`{applied: false, skip_reason: "trigram_native"}` on a zero-hit lexical query,
|
|
73
|
+
and `match_type: "implicit_wildcard"` stops being produced.
|
|
74
|
+
- **CJK semantics**: ≥3-codepoint substrings match natively at any position
|
|
75
|
+
(measured: `搜索测试` → 3 hits, 0.39 ms); a 2-codepoint CJK query has no
|
|
76
|
+
trigram to build and degrades to `LIKE` — that is the honest behaviour, and
|
|
77
|
+
it measures 189–289 ms.
|
|
78
|
+
|
|
79
|
+
Ordering and snippet generation for the degraded path, per Wake
|
|
80
|
+
(`db.rs#L1632-L1650`, `#L2016-L2043`): segments escape `\`, `%`, `_`; the plan
|
|
81
|
+
must early-exit (ordering by `rowid DESC` with the `LIMIT` measured 0.2 ms for a
|
|
82
|
+
common term where ordering by session time measured 310 ms); snippets are
|
|
83
|
+
windowed in JS around the first matching segment (`make_like_snippet`: 40 chars
|
|
84
|
+
before, match in `«»`, 80 after).
|
|
85
|
+
|
|
86
|
+
## Measurements (OBSERVED)
|
|
87
|
+
|
|
88
|
+
Host: Apple M1, macOS 25.6, APFS; Bun 1.3.14 (SQLite **3.51.0**, trigram and
|
|
89
|
+
`case_sensitive 0` verified compiled in — the decision round verified the same
|
|
90
|
+
SQLite under Bun 1.4.2). Fixture: a byte-consistent copy of the production store
|
|
91
|
+
taken with `sqlite3 ".backup"` (the live store is only ever read). The
|
|
92
|
+
one-shot was run exactly as `reindexFts()` runs it — drop, recreate, repopulate,
|
|
93
|
+
echo-subtract, automerge, both gates, one transaction — twice, on two copies.
|
|
94
|
+
|
|
95
|
+
**Size.** `dbstat` per shadow table, after checkpoint; the file is the page
|
|
96
|
+
count × 4096, no significant freelist left (241 pages).
|
|
97
|
+
|
|
98
|
+
| shadow table | porter (format "3") | trigram | ratio |
|
|
99
|
+
| --- | --- | --- | --- |
|
|
100
|
+
| `records_fts_data` | 100,507,648 | 697,552,896 | 6.94× |
|
|
101
|
+
| `records_fts_idx` | 290,816 | 888,832 | 3.06× |
|
|
102
|
+
| `records_fts_content` | 280,817,664 | 280,829,952 | 1.00× |
|
|
103
|
+
| `records_fts_docsize` | 3,985,408 | 4,091,904 | 1.03× |
|
|
104
|
+
| `records_fts`, all | 385,605,632 | 983,367,680 | 2.55× |
|
|
105
|
+
| `titles_fts`, all | 700,416 | 1,806,336 | 2.58× |
|
|
106
|
+
| all FTS shadow | 386,306,048 | 985,174,016 | 2.55× |
|
|
107
|
+
| **`_data` + `_idx` (the bar's basis)** | **100,798,464** | **698,441,728** | **6.93×** |
|
|
108
|
+
| **store file** | **1,134,215,168** | **1,729,425,408** | **+52.5%** |
|
|
109
|
+
|
|
110
|
+
The indexed text is 197,656,948 bytes (59.1 MB `content`, 93.1 MB
|
|
111
|
+
`tool_result`, the rest tool names and arguments): trigram costs 3.5 bytes of
|
|
112
|
+
index per byte of text where porter cost 0.51. The `_content` shadow is a
|
|
113
|
+
1.42× duplicate of that text and is the only large removable term (below).
|
|
114
|
+
|
|
115
|
+
**One-shot re-derivation** (two copies, identical statement set):
|
|
116
|
+
|
|
117
|
+
| phase | run 1 | run 2 |
|
|
118
|
+
| --- | --- | --- |
|
|
119
|
+
| drop + DDL `records_fts` | 3.84 s | — |
|
|
120
|
+
| repopulate from `records` | 40.05 s | — |
|
|
121
|
+
| echo subtraction | 0.58 s | — |
|
|
122
|
+
| automerge (both) | 0.01 s | — |
|
|
123
|
+
| `titles_fts` rebuild | 0.05 s | — |
|
|
124
|
+
| FTS5 `integrity-check` (records / titles) | 9.80 s / 0.02 s | 8.81 s / 0.02 s |
|
|
125
|
+
| **total, flag clear included** | **54.34 s** | **54.56 s** |
|
|
126
|
+
|
|
127
|
+
For scale, the same one-shot under format "3" measured **15.8 s** on this store
|
|
128
|
+
(C4's note): the trigram re-derivation is 3.4× the porter one, and the porter
|
|
129
|
+
gate alone was 5.0 s where the trigram gate is 9.8 s.
|
|
130
|
+
|
|
131
|
+
**Query classes.** The reader's own SQL shape (join `sessions`, `bm25`, three
|
|
132
|
+
`snippet()` columns, `ORDER BY rank, started_at DESC LIMIT 50`), 3 warmups + 50
|
|
133
|
+
iterations; hit counts are `COUNT(*) … MATCH`.
|
|
134
|
+
|
|
135
|
+
| query | trigram hits | median | p95 | porter baseline (same query) |
|
|
136
|
+
| --- | --- | --- | --- | --- |
|
|
137
|
+
| `Session` | 36,242 | 190.4 ms (re-run 200.6) | 202 ms | 163.9 ms (34,391 hits) |
|
|
138
|
+
| `getSessionStore` | 19 | 16.4 ms (re-run 15.7) | 17.7 ms | — |
|
|
139
|
+
| `useEffect(` | 39 | 6.0 ms | 6.1 ms | — |
|
|
140
|
+
| `content_snippet` | 14 | 4.5 ms | 4.8 ms | 1.2 ms (16 hits) |
|
|
141
|
+
| `readWidenMaxRecords` | 9 | 3.5 ms | 3.7 ms | — |
|
|
142
|
+
| `搜索测试` (3+ CJK) | 3 | 0.39 ms | 0.40 ms | 1 hit (token-granular) |
|
|
143
|
+
| `中文搜索` (3+ CJK) | 2 | 0.21 ms | 0.22 ms | 0 hits (token-granular) |
|
|
144
|
+
| `搜索` (2 CJK → LIKE) | 0 native | 189 ms | 202 ms | — |
|
|
145
|
+
| `db` / `fs` / `ui` / `io` → LIKE | early exit | 0.15–1.10 ms | 1.18 ms | — |
|
|
146
|
+
| absent term → LIKE (`%zqxq%`) | 0 | 248 ms | — | — |
|
|
147
|
+
| `fair AND use AND throttle` | 8 | 6.8 ms | 7.1 ms | 0.88 ms (5 hits, phrase) |
|
|
148
|
+
| `worktree AND premortem AND fix` | 703 | 39.3 ms | 39.7 ms | 1.58 ms (5 hits, phrase) |
|
|
149
|
+
| `rebase AND token AND passthrough` | 25 | 18.5 ms | 18.7 ms | 2.22 ms (21 hits, phrase) |
|
|
150
|
+
| `the` | 79,122 | 456 ms (re-run 391) | 484 ms | 418 ms (`the*` widened) |
|
|
151
|
+
| `run` | 70,707 | 394 ms | 454 ms | 277 ms (`run*`) |
|
|
152
|
+
| `test` | 47,735 | 283 ms | 309 ms | 214 ms (`test*`) |
|
|
153
|
+
|
|
154
|
+
Supporting observations, same fixture:
|
|
155
|
+
|
|
156
|
+
- `snippet()`/`highlight()` work under trigram (a `getSessionStore` hit came
|
|
157
|
+
back as `… («getSessionStore» → s…`), bm25 produced sane ranks.
|
|
158
|
+
- The `LIKE` plan's two cost regimes: `rowid DESC` + `LIMIT` early-exits on
|
|
159
|
+
common terms (0.2 ms for `db`), and scans everything when few or no rows
|
|
160
|
+
match (248–289 ms for 0–72 matching rows). `NOT IN (SELECT … records_echo)`
|
|
161
|
+
and `NOT EXISTS` measured identically (0.20 ms / 13.5 ms).
|
|
162
|
+
- Ordering the degraded `LIKE` by session time instead of `rowid` removes the
|
|
163
|
+
early exit: 310 ms for `db`, 267 ms even for a term with no hits.
|
|
164
|
+
- The title lane is trivial either way (2,612 rows): trigram `trigram` 0.05 ms,
|
|
165
|
+
`LIKE '%tr%'` 0.07 ms.
|
|
166
|
+
- A FTS5 `integrity-check` passes on a trigram table (it is the 9.8 s line
|
|
167
|
+
above), so the C2/C3/C4 gates stay valid under the swap.
|
|
168
|
+
|
|
169
|
+
## Why the costs are where they are (INFERRED, marked as such)
|
|
170
|
+
|
|
171
|
+
- The 190–200 ms for `Session` and the ~400 ms for `the` are match-set bound:
|
|
172
|
+
bm25 must score every matching row and `ORDER BY rank` cannot stop at the
|
|
173
|
+
`LIMIT`. Porter pays the same on the same query (163.9 ms / 418 ms), so
|
|
174
|
+
trigram is at parity, not a regression — but it is not the ≤15 ms / ≤25 ms the
|
|
175
|
+
decision round projected, and no tokenizer choice alone changes that.
|
|
176
|
+
- The re-derivation cost is dominated by tokenization (40 s) plus the
|
|
177
|
+
`integrity-check` (9.8 s), both O(index size). It scales with the corpus and
|
|
178
|
+
the store keeps growing (156,801 records at the decision round, 230,220
|
|
179
|
+
measured here), so a re-decision should re-measure, not rescale.
|
|
180
|
+
- The full-scan `LIKE` band is O(rows × indexed columns) with no index to help
|
|
181
|
+
it; 248–289 ms on 198 MB of text is the floor for a total scan, and a store
|
|
182
|
+
with larger tool results would be worse.
|
|
183
|
+
- Class 4 is not just slower, it is looser: the compiled expression changes the
|
|
184
|
+
current phrase semantics to AND-joined segments, and the hit counts move with
|
|
185
|
+
it (`worktree premortem fix`: 5 phrase hits → 703 AND hits; `fair use
|
|
186
|
+
throttle`: 5 → 8). That is Wake's shape and would be a deliberate
|
|
187
|
+
recall-for-precision trade for lexical search — worth deciding explicitly if
|
|
188
|
+
the cycle reopens, but it is not what the class-4 bar was measuring.
|
|
189
|
+
- The measured runs were warm (page cache) and the host was not idle (load
|
|
190
|
+
average ~2.5 with the agent stack live). That cuts both ways: a cold or busier
|
|
191
|
+
machine is slower, and "idle" in the bar's wording was not available; the
|
|
192
|
+
porter baseline measured on the same host (15.8 s) is the honest comparison,
|
|
193
|
+
and the 3.4× multiplier is tokenizer work, not host noise.
|
|
194
|
+
|
|
195
|
+
## The one measured size lever (a variant, not the decided design)
|
|
196
|
+
|
|
197
|
+
`records_fts` stores its indexed columns in `records_fts_content`, a 281 MB
|
|
198
|
+
copy. A contentless variant (`content='', contentless_delete=1`, same trigram
|
|
199
|
+
tokenizer) rebuilds to **702,992,384 B** of shadow (data 697,335,808 + docsize
|
|
200
|
+
4,775,936 + idx 880,640) and a **logical** store of 1,448,067,072 B (+27.7%) —
|
|
201
|
+
under the +35% bar. It is not free: the file only shrinks with a `VACUUM`
|
|
202
|
+
(the variant left 281 MB of freelist; the decided DDL leaves none), `snippet()`
|
|
203
|
+
and every projected column come back `NULL` from a contentless table (measured:
|
|
204
|
+
`record_id`/`content`/`snippet` null, `MATCH` and `bm25` still correct), so the
|
|
205
|
+
record lane would have to join `records` and window snippets in JS — the
|
|
206
|
+
mechanism the degraded `LIKE` path needs anyway. That is a reader redesign, a
|
|
207
|
+
different cycle, and it does not move the 6.93× shadow bar or the 54 s
|
|
208
|
+
one-shot: the index itself and its build are the cost.
|
|
209
|
+
|
|
210
|
+
## What would reopen C5
|
|
211
|
+
|
|
212
|
+
1. **A re-derived bar table** (one budget, both metrics) and an owner decision to
|
|
213
|
+
accept either +52.5% / 1.73 GB or the contentless redesign.
|
|
214
|
+
2. **Candidate capping** for the common-substring classes (`Session`, `the`):
|
|
215
|
+
#34's closing comment already names candidate-capping as the fix shape for
|
|
216
|
+
the match-set-bound floor; it is orthogonal to the tokenizer and would make
|
|
217
|
+
the recall half of this design affordable.
|
|
218
|
+
3. **A corpus whose shape differs** (fewer, smaller tool results; a
|
|
219
|
+
shadow-share far below this store's 34%) — the arithmetic, not the
|
|
220
|
+
tokenizer, is what fails here.
|
|
221
|
+
|
|
222
|
+
## Roadmap context and the open-source case (program doc, C5 row)
|
|
223
|
+
|
|
224
|
+
> **C5** | Trigram tokenizer decision + swap (with whole-query `LIKE`
|
|
225
|
+
> degradation, surfaced in the envelope) | #34 common-prefix floor: mid-token
|
|
226
|
+
> substrings and CJK are unmatchable under `porter unicode61` | Code-identifier
|
|
227
|
+
> search is the agent's query shape (`useEffect(`); Wake proves it works in
|
|
228
|
+
> SQLite FTS5 alone
|
|
229
|
+
|
|
230
|
+
Both halves of that rationale were tested. The failure half is confirmed:
|
|
231
|
+
mid-token substrings and CJK are unmatchable today, and the swap fixes recall
|
|
232
|
+
(`Session` inside `getSessionStore`, `useEffect(`, 3-codepoint CJK). The cost
|
|
233
|
+
half is what the program doc's C5 row did not price: the swap costs +52.5% of
|
|
234
|
+
the store, a 54 s one-shot, and it leaves the common-prefix floor standing at
|
|
235
|
+
~0.4 s. Its dependency note — "C6 after C5 (ranking experiments on the final
|
|
236
|
+
tokenizer)" — resolves to *no tokenizer change*: C6 can proceed on the porter
|
|
237
|
+
index, and any ranking work should be evaluated on this store's measured
|
|
238
|
+
match-set classes rather than on a hoped-for cheaper tokenizer.
|
|
239
|
+
|
|
240
|
+
## Method (for reproducibility)
|
|
241
|
+
|
|
242
|
+
Copy the live store with SQLite's own backup API (`.backup /tmp/…` — a read
|
|
243
|
+
transaction against the original; the store was never opened for writing), then
|
|
244
|
+
on the copy: drop and recreate both FTS tables with the candidate DDL,
|
|
245
|
+
repopulate from `records` / `sessions.title`, subtract `records_echo`, reset
|
|
246
|
+
automerge (`INSERT INTO records_fts(records_fts, rank) VALUES('automerge', 4)`),
|
|
247
|
+
run both gates, and time the whole transaction; sizes are `dbstat` sums plus
|
|
248
|
+
`page_count` × `page_size` after `wal_checkpoint(TRUNCATE)`; query timings use
|
|
249
|
+
the reader's own SQL shape with warmups. The harness lived in `/tmp` and was
|
|
250
|
+
deleted; the numbers above are the runs described here, and the tree is
|
|
251
|
+
untouched by this cycle.
|
|
252
|
+
|
|
253
|
+
## Wake pins (MIT, `iAmCorey/Wake@d9f702b6` tag v0.6.5)
|
|
254
|
+
|
|
255
|
+
| mechanism | pin |
|
|
256
|
+
| --- | --- |
|
|
257
|
+
| trigram tokenizer on the message index | `crates/wake-core/src/db.rs#L63-L67` (`tokenize="trigram case_sensitive 0"`) |
|
|
258
|
+
| trigram tokenizer on the title index | `db.rs#L126-L130` |
|
|
259
|
+
| whole-query degradation predicate | `db.rs#L1540` (segments), `#L1544` (`chars().count() < 3`) |
|
|
260
|
+
| AND-joined quoted segments | `db.rs#L1587-L1591` |
|
|
261
|
+
| degraded body `LIKE … ESCAPE '\'`, `ORDER BY ts DESC LIMIT ?` | `db.rs#L1632-L1650` |
|
|
262
|
+
| degraded title `LIKE … ESCAPE '\'` | `db.rs#L1706` |
|
|
263
|
+
| `escape_like` / `make_like_snippet` windowing | `db.rs#L2016-L2043` |
|
|
264
|
+
|
|
265
|
+
## Files
|
|
266
|
+
|
|
267
|
+
None. This cycle changed no source, no DDL, no counter, no test, and no
|
|
268
|
+
`api-surface.md` statement; this note is its whole artifact.
|