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.
Files changed (205) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +201 -0
  3. package/README.md +288 -2
  4. package/dist/cli.js +30210 -0
  5. package/dist/convert.d.ts +31 -0
  6. package/dist/convert.js +431 -0
  7. package/dist/deepagents_checkpoint.py +362 -0
  8. package/dist/index.d.ts +7 -0
  9. package/dist/index.js +8402 -0
  10. package/dist/log-baseline.d.ts +26 -0
  11. package/dist/log-baseline.js +104 -0
  12. package/dist/log-content.d.ts +16 -0
  13. package/dist/log-content.js +80 -0
  14. package/dist/log-io.d.ts +10 -0
  15. package/dist/log-io.js +121 -0
  16. package/dist/log-read.d.ts +24 -0
  17. package/dist/log-read.js +117 -0
  18. package/dist/log-reconcile.d.ts +5 -0
  19. package/dist/log-reconcile.js +38 -0
  20. package/dist/log-records.d.ts +35 -0
  21. package/dist/log-records.js +85 -0
  22. package/dist/log-recovery.d.ts +18 -0
  23. package/dist/log-recovery.js +123 -0
  24. package/dist/log-stage.d.ts +14 -0
  25. package/dist/log-stage.js +138 -0
  26. package/dist/log-state.d.ts +89 -0
  27. package/dist/log-state.js +92 -0
  28. package/dist/log-switch.d.ts +3 -0
  29. package/dist/log-switch.js +98 -0
  30. package/dist/log-types.d.ts +129 -0
  31. package/dist/log-types.js +8 -0
  32. package/dist/log.d.ts +8 -0
  33. package/dist/log.js +62 -0
  34. package/dist/migrate.d.ts +29 -0
  35. package/dist/migrate.js +173 -0
  36. package/dist/normalization-runtime.d.ts +1 -0
  37. package/dist/normalization-runtime.js +3150 -0
  38. package/dist/portable.d.ts +9 -0
  39. package/dist/portable.js +26 -0
  40. package/dist/readers/canonical.d.ts +13 -0
  41. package/dist/readers/canonical.js +54 -0
  42. package/dist/readers/cline.d.ts +10 -0
  43. package/dist/readers/cline.js +107 -0
  44. package/dist/readers/cursor.d.ts +20 -0
  45. package/dist/readers/cursor.js +374 -0
  46. package/dist/readers/deepagents.d.ts +41 -0
  47. package/dist/readers/deepagents.js +163 -0
  48. package/dist/readers/fx.d.ts +11 -0
  49. package/dist/readers/fx.js +573 -0
  50. package/dist/readers/github-copilot.d.ts +8 -0
  51. package/dist/readers/github-copilot.js +216 -0
  52. package/dist/readers/grok-build.d.ts +12 -0
  53. package/dist/readers/grok-build.js +391 -0
  54. package/dist/readers/guards.d.ts +4 -0
  55. package/dist/readers/guards.js +121 -0
  56. package/dist/readers/pi-compaction.d.ts +7 -0
  57. package/dist/readers/pi-compaction.js +112 -0
  58. package/dist/readers/pi-context.d.ts +13 -0
  59. package/dist/readers/pi-context.js +64 -0
  60. package/dist/store.d.ts +25 -0
  61. package/dist/store.js +218 -0
  62. package/dist/types.d.ts +91 -0
  63. package/dist/types.js +1 -0
  64. package/dist/writers/claude-code.d.ts +5 -0
  65. package/dist/writers/claude-code.js +51 -0
  66. package/dist/writers/cline.d.ts +3 -0
  67. package/dist/writers/cline.js +77 -0
  68. package/dist/writers/codex.d.ts +3 -0
  69. package/dist/writers/codex.js +32 -0
  70. package/dist/writers/cursor.d.ts +3 -0
  71. package/dist/writers/cursor.js +197 -0
  72. package/dist/writers/deepagents.d.ts +3 -0
  73. package/dist/writers/deepagents.js +116 -0
  74. package/dist/writers/entropy.d.ts +5 -0
  75. package/dist/writers/entropy.js +33 -0
  76. package/dist/writers/fx.d.ts +3 -0
  77. package/dist/writers/fx.js +135 -0
  78. package/dist/writers/github-copilot.d.ts +3 -0
  79. package/dist/writers/github-copilot.js +71 -0
  80. package/dist/writers/grok-build.d.ts +3 -0
  81. package/dist/writers/grok-build.js +43 -0
  82. package/dist/writers/helpers.d.ts +17 -0
  83. package/dist/writers/helpers.js +75 -0
  84. package/dist/writers/omp.d.ts +3 -0
  85. package/dist/writers/omp.js +7 -0
  86. package/dist/writers/opencode.d.ts +3 -0
  87. package/dist/writers/opencode.js +65 -0
  88. package/dist/writers/pi.d.ts +3 -0
  89. package/dist/writers/pi.js +45 -0
  90. package/docs/cline-native-format.md +43 -0
  91. package/docs/copilot-native-format.md +78 -0
  92. package/docs/cursor-native-format.md +103 -0
  93. package/docs/deepagents-native-format.md +98 -0
  94. package/docs/evidence-api.md +170 -0
  95. package/docs/fx-native-format.md +87 -0
  96. package/docs/harness-parity-bootstrap.md +78 -0
  97. package/docs/harness-support-parity.md +107 -0
  98. package/docs/inspirations.md +103 -0
  99. package/docs/ledger-migration.md +141 -0
  100. package/docs/pi-compaction-context.md +75 -0
  101. package/docs/pi-provider-context.md +76 -0
  102. package/docs/research/2026-10-06-huihua-and-oar.md +176 -0
  103. package/docs/research/ledger/README.md +96 -0
  104. package/docs/research/ledger/confer/2026-09-13-round-10/README.md +61 -0
  105. package/docs/research/ledger/confer/2026-09-13-round-2/cass.md +72 -0
  106. package/docs/research/ledger/confer/2026-09-13-round-3/README.md +70 -0
  107. package/docs/research/ledger/confer/2026-09-13-round-5/README.md +61 -0
  108. package/docs/research/ledger/confer/2026-09-13-round-6/README.md +68 -0
  109. package/docs/research/ledger/confer/2026-09-13-round-7/README.md +64 -0
  110. package/docs/research/ledger/confer/2026-09-13-round-9/README.md +59 -0
  111. package/docs/research/ledger/confer/2026-09-14-wake/wake.md +146 -0
  112. package/docs/research/ledger/docs/inspirations.md +320 -0
  113. package/docs/research/ledger/docs/pr2-pr6-integration.md +53 -0
  114. package/docs/research/ledger/docs/research/2026-09-07-confer-inspirations.md +163 -0
  115. package/docs/research/ledger/docs/research/2026-09-07-letta-continuous-service.md +104 -0
  116. package/docs/research/ledger/docs/research/2026-09-08-claude-history-retrieval.md +117 -0
  117. package/docs/research/ledger/docs/research/2026-09-09-agentsview-native-discovery.md +87 -0
  118. package/docs/research/ledger/docs/research/2026-09-09-daemon-observation.md +231 -0
  119. package/docs/research/ledger/docs/research/2026-09-09-discovery-pagination.md +114 -0
  120. package/docs/research/ledger/docs/research/2026-09-09-harness-transcript-contract.md +145 -0
  121. package/docs/research/ledger/docs/research/2026-09-09-native-history-discovery.md +180 -0
  122. package/docs/research/ledger/docs/research/2026-09-09-native-history-implementation.md +137 -0
  123. package/docs/research/ledger/docs/research/2026-09-09-other-inspirations.md +279 -0
  124. package/docs/research/ledger/docs/research/2026-09-09-trajectory-and-letta-code.md +128 -0
  125. package/docs/research/ledger/docs/research/2026-09-13-diagnostics-coverage.md +116 -0
  126. package/docs/research/ledger/docs/research/2026-09-13-fts-rebuild.md +83 -0
  127. package/docs/research/ledger/docs/research/2026-09-13-read-path-split.md +74 -0
  128. package/docs/research/ledger/docs/research/2026-09-13-runtime-rollout.md +102 -0
  129. package/docs/research/ledger/docs/research/2026-09-13-scan-outcomes.md +80 -0
  130. package/docs/research/ledger/docs/research/2026-09-13-search-quality.md +105 -0
  131. package/docs/research/ledger/docs/research/2026-09-13-source-coverage.md +64 -0
  132. package/docs/research/ledger/docs/research/2026-09-13-sync-wall-budget.md +82 -0
  133. package/docs/research/ledger/docs/research/2026-09-13-typed-read-gate.md +81 -0
  134. package/docs/research/ledger/docs/research/2026-09-13-writer-lease.md +84 -0
  135. package/docs/research/ledger/docs/research/2026-09-16-c1-copy-fallback.md +227 -0
  136. package/docs/research/ledger/docs/research/2026-09-16-c10-kimi.md +414 -0
  137. package/docs/research/ledger/docs/research/2026-09-16-c2-derive-counter.md +216 -0
  138. package/docs/research/ledger/docs/research/2026-09-16-c3-echo-filter.md +305 -0
  139. package/docs/research/ledger/docs/research/2026-09-16-c4-title-index.md +349 -0
  140. package/docs/research/ledger/docs/research/2026-09-16-c5-trigram.md +268 -0
  141. package/docs/research/ledger/docs/research/2026-09-16-c6-recency-prior.md +438 -0
  142. package/docs/research/ledger/docs/research/2026-09-16-c7-tombstones.md +467 -0
  143. package/docs/research/ledger/docs/research/2026-09-16-c8-remote-truth.md +390 -0
  144. package/docs/research/ledger/docs/research/2026-09-16-c9-grok.md +380 -0
  145. package/docs/research/ledger/docs/research/2026-09-16-wake-port-program.md +38 -0
  146. package/docs/research/ledger/docs/research/2026-09-17-cursor-blobstore.md +157 -0
  147. package/docs/research/ledger/manifest.json +540 -0
  148. package/legacy/NOTICE.md +50 -0
  149. package/legacy/bin/ledger-core.ts +7 -0
  150. package/legacy/bin/ledger.ts +7 -0
  151. package/legacy/bin/traj-bridge.ts +7 -0
  152. package/legacy/scripts/import-claude-history.ts +130 -0
  153. package/legacy/scripts/native-history-smoke.ts +52 -0
  154. package/legacy/scripts/observe-daemon.ts +570 -0
  155. package/legacy/scripts/retitle-sessions.md +19 -0
  156. package/legacy/scripts/verify-runtime-rollout.ts +151 -0
  157. package/legacy/src/canonical.ts +108 -0
  158. package/legacy/src/cli.ts +1293 -0
  159. package/legacy/src/db.ts +2254 -0
  160. package/legacy/src/diagnostics.ts +87 -0
  161. package/legacy/src/discovery.ts +56 -0
  162. package/legacy/src/embeddings.ts +382 -0
  163. package/legacy/src/filter.ts +602 -0
  164. package/legacy/src/gemini_embeddings.ts +103 -0
  165. package/legacy/src/identity.ts +9 -0
  166. package/legacy/src/index.ts +10 -0
  167. package/legacy/src/ingest.ts +791 -0
  168. package/legacy/src/managed.ts +178 -0
  169. package/legacy/src/native/common.ts +68 -0
  170. package/legacy/src/native/cursor.ts +647 -0
  171. package/legacy/src/native/grok.ts +540 -0
  172. package/legacy/src/native/kimi.ts +706 -0
  173. package/legacy/src/native/opencode.ts +470 -0
  174. package/legacy/src/native/sqlite-ro.ts +281 -0
  175. package/legacy/src/native.ts +97 -0
  176. package/legacy/src/normalizer.ts +457 -0
  177. package/legacy/src/parentage.ts +562 -0
  178. package/legacy/src/pressure.ts +162 -0
  179. package/legacy/src/private-storage.ts +74 -0
  180. package/legacy/src/reader.ts +1255 -0
  181. package/legacy/src/scanner.ts +591 -0
  182. package/legacy/src/schema-gate.ts +263 -0
  183. package/legacy/src/schema.ts +829 -0
  184. package/legacy/src/semantic-top-k.ts +71 -0
  185. package/legacy/src/session-hash.ts +49 -0
  186. package/legacy/src/store-status.ts +218 -0
  187. package/legacy/src/sync-preparation.ts +45 -0
  188. package/legacy/src/sync-truth.ts +843 -0
  189. package/legacy/src/sync.ts +1233 -0
  190. package/legacy/src/types.ts +436 -0
  191. package/legacy/src/vocab.txt +30522 -0
  192. package/legacy/src/watch.ts +673 -0
  193. package/legacy/src/writer-lease.ts +310 -0
  194. package/ops/README.md +150 -0
  195. package/ops/install-cli.sh +63 -0
  196. package/ops/install-native.sh +35 -0
  197. package/ops/install-release.sh +49 -0
  198. package/ops/linux/install-systemd-user.sh +103 -0
  199. package/ops/macos/install-launchd.sh +72 -0
  200. package/ops/sync-settings.sh +37 -0
  201. package/package.json +21 -5
  202. package/skills/trajectories/SKILL.md +138 -0
  203. package/skills/trajectories/scripts/trajectories_recent.sh +26 -0
  204. package/skills/trajectories/scripts/trajectories_search.sh +26 -0
  205. 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.