Repository navigation
Conversation
…ches Three of the remaining poll-cost items, in the order they compound. countRolesInPrefix counted the list's eight counters with five matchAll passes over the bounded prefix — a match object per hit, thousands of them per session per rebuild. It now runs four test-driven scans that read each value out of the text by position. The scans carry /g and test mutates lastIndex, so each resets it first: the loops break when a value has no closing quote, which is the normal case for a truncated prefix and leaves the cursor mid-string on module state shared across calls. verify-count-roles.mjs holds the capturing implementation it replaced as a reference and requires the two to agree over hand-written edges and over every session prefix in the store it is pointed at, so the rewrite is held to being the same function and not merely a faster one. /api/sessions had no validator while /api/trajectory got one first. sessionsValidator is built from the same values the payload is built from, in the same request: the visible slice's dir|sizeBytes|lastActiveAt — exactly what describeSession keys its descriptor cache on — plus the titles, which no file stat can see, plus the limit, because ?limit=10 and ?limit=20 are different payloads and must not satisfy one another. The 304 is decided before any descriptor is built, and an unchanged list answers from the bytes the previous request already built. The soundness bound is the descriptor cache's and not a new one: a manifest.json appearing after the walk listed a session would change startedAt without moving the tag, and that is already invisible for up to SESSION_CACHE_TTL_MS to the cache this reuses. verify-list-cache.mjs pins the TTL relationship that says so. One POLL_INTERVAL drove both fetches and it was wrong for both. The list is a cached walk and a cached title read and now usually a 304; the trajectory reparses a lineage, rebuilds a payload that measured 18,924 rows, and reopens the runtime database on every request. They are two ticks now, with the expensive one slower, and the list wakes the trajectory when it has evidence: a 200 means something in the visible slice moved, a 304 means nothing on screen can be stale. So the slow tick is a floor on latency rather than a delay applied to every change. The list keeps the fast tick for a reader-visible reason too: the picker renders 「刚刚」 against a render-time clock, so a slower tick makes that label sit still and then jump. That ordering is asserted, not just argued in a comment. The ledger build guard moves with the trajectory, where it belongs — it exists because a table mid-build must not be replaced mid-build, and the list never renders the ledger. Read points that moved with it: verify-list-cache.mjs measures the cache against the list's own cadence rather than the page's, client-check's poll assertion no longer passes just because SESSION_POLL_INTERVAL contains POLL_INTERVAL as a substring, and the list body cache joins the others in dispose. 63/63 guards, 33 mutations across the three new controls.
`collectSessionEntries` did two jobs with one return value: it walked the
store, and it returned `entries.slice(0, MAX_SESSION_LIST)`. Because the
slice was the last statement, the only handle the service had on a session
directory was "one of the 30 most recently touched" — and resolving an
explicit `?session=<id>` scanned exactly those 30.
Measured against a real store: 146 candidate directories, 30 exposed, and
116 of them answering 404 to their own id. Inside the window 200/200; a
sample outside 404/24. No leakage at the boundary, which is what a single
truncation point looks like. A session the reader could name but not open is
a correctness defect, and the runtime database cannot paper over it: the six
compacted sessions on that machine have no `local_runtime_sessions` row at
all, so the `latest` branch's `relativeDir` fallback has nothing to find.
The traversal now yields two products and the two callers take different
ones. `recentEntries` is sorted and capped, and it is still the only thing
the picker is built from. `byDecodedId` is every candidate keyed by the id
decoded from its own name; the walk already stat'ed `messages.jsonl` for
each candidate, so the index costs no I/O the walk was not doing anyway.
Three things the index deliberately does not get to do on its own:
* A decoded name is a claim. `readSessionIdentity` prefers
`manifest.sessionId`, so an index hit still reads the identity of the one
directory it picked and compares before serving.
* Two directories can decode to one id. `Map.set` would keep whichever
came last and make the answer depend on `readdir` order, so duplicates
are kept as a list and the manifests resolve them.
* A name that does not decode is still a real session. It gets no fast
path; it does not become "does not exist".
`MAX_SESSION_CANDIDATES` stays a resource guard, but it stops being a
correctness boundary by saying so. When the walk stops early the route
answers `session_scan_budget_exhausted`, which is a different answer from
`session_not_found` and only one of the two is true.
The client seeds `state.sessionId` from the host id. `sessionParam()`
already preferred `hostSessionId`, so a host-pinned page rendered
「跟随最新(自动切换)」 above content pinned to a different conversation
that it would never leave. That contradiction predates this change, but the
reachability fix turns it from a visible error into a silent one.
The picker is untouched: still 30 candidates, still `?limit=20`, still a
server cap of 50. Widening it is a UI decision and is not taken here.
Guards: `verify-session-reachability.mjs` holds 31 assertions over a
synthetic 40-session store and a 405-session store that runs the walk out of
budget, and `mutate-session-reachability.py` breaks each of them in the
shipped source and requires the guard to report it by name — 15 mutations,
all caught. Suite is 65/65.
trajectoryCacheKey carries the active file's size and mtime, so a session that is still being written to mints a new key every time it grows. Every miss then stores another complete body -- ?since= included, because the cache entry and /raw both need the whole thing -- and the eviction was a plain FIFO over keys. Four versions of the SAME (session, slice) therefore sat there at once, three of them unreachable: /raw resolves the current key, and the page's next poll produces a newer one. Measured on a 9.1 MB session polled every 8s: four bodies, 49.6 MB, after four polls. On this machine's 68 MB body that is three dead copies. An entry now carries the family it answers for -- the session directory plus the requested slice -- and storing a new one drops its predecessor. Everything that decides whether the answer went stale stays in the key; everything that decides what the answer IS stays in the family. trajectoryCacheFamily is exported and used by both the key and the cache, so the two copies of that expression cannot drift. Different ?generation= slices stay separate families and keep their own entries, still bounded by the same limit. Reading still refreshes recency. verify-trajectory-cache.mjs: 30 assertions, including that the key still carries size and lineage -- a stale entry must never become servable just because the family matched. mutate-trajectory-cache.py: 10 mutations, each breaking a different part of the mechanism, because a bug collapsing two of them would still be caught by the wrong assertion. Two anchors had to move, and the mutations stayed: the raw-lazy set call grew its family argument, and createTrajectoryCache now has a clear() of its own, which made the parsed-row cache's clear() anchor ambiguous. That harness reports an ambiguous anchor as unresolved rather than guessing, so the anchor grew the bytes that make it specific. Suite 67/67.
verify-parsed-rows.mjs built its stand-in session as
`{ session: {...}, sizeBytes: 0, truncated: false }`, then spread it into the
per-file result with `...other` AFTER the loop had computed both numbers. The
guard replaced its own measurements with two constants one line before comparing
them: every assertion about total size and the truncated flag was asserting the
literal back at itself.
Those are exactly the two quantities the route derives by walking the store --
how many bytes the generation actually holds, and whether the cap cut it. The
guard was therefore free to disagree with the route about either without any
assertion noticing.
The stand-in now carries `session` and nothing else. Nothing else needed to
change: the computed values survive to be compared, which is the point.
53 assertions and all 12 mutations behave the same as before, so what the guard
was actually pinning is unchanged -- it just stops pinning a constant.
Found while measuring what parsed rows are worth to hold in memory: the row
cache's value was being judged partly by a guard that could not disagree with
itself.
The body was already serialized by the time /raw was asked for, and the entry kept the whole parsed object graph behind it so that one record could be handed back. Measured on this machine's own store: 377 MB of heap held while one 71.8 MB session was open, against 304 MB once the rows were not retained -- and on a session with a 20.5 MB source, 31 MB against 11. Polling does not change either way: 304 of 304 answered 304, at 1.6 ms each. The parsed-rows cache could not have been the fix, because it was never a cache for the file that mattered. Its key is path+size+mtime, and handleTrajectory only reaches the parse after the trajectory key -- carrying that same size and mtime -- has already missed. Every store was followed by a miss. Sealed generations are the opposite case and stay cached, because for those the stat really is constant. So the active generation is parsed every time and retained never, and what the entry keeps instead is, per record: the file, the byte range of its line, and a SHA-256 of that line. 36,527 locators cost 5 MB against the object graph's 73. `parseMessagesJsonl` reads the bytes `readHead` already has and reports where each line sits, which also stops a 68 MB JSONL being decoded into a 135 MB JS string before it is parsed at all. /raw seeks to the range, refuses anything whose length or digest does not match, and answers 409 raw_stale rather than a plausible message from the wrong line. That case is narrow -- the entry is keyed on size and mtime, so only a rewrite preserving both reaches it, and no sealed generation on this store has ever been rewritten -- but nothing above the file level can see it, and a wrong message under the right record's name is worse than a hint. The page puts the validator in its own raw cache key for the same reason: a record index is a position within one version of one body, and a body that has moved on would otherwise keep its old messages. The cost is on re-parses only, and it scales with record count rather than file size: 574 records cost 25 ms, 36,527 cost 342 ms. 304s are unaffected. Reads now answer one line at a time out of a file rather than out of memory, and a record the page has already opened is kept decoded for the entry, 32 at a time. verify-raw-locators.mjs: 57 assertions, over the three things the seam can get wrong -- the positions being byte offsets rather than character indices (the fixture carries a CJK line, where the two part company at the line's end and agree at its start), the digest meaning something under a same-length rewrite, and the route actually keeping locators instead of rows. mutate-raw-locators.py: 20 mutations. Four existing mutation controls had anchors this change invalidated, and the anchors moved rather than the mutations: two named the old cache-entry line, one named a comment that was re-wrapped, two named `empty` and the inline-parse call site. mutate-review16's own anchor had to move with them, which is the third time a re-wrap has cost a detector its anchor. Also verified, and it is the part worth keeping: no sealed generation on this store has ever been rewritten (52 of them, checked against their own catalog's byteLength and mtime), and the lineage at a session root is the active file alone -- rotated generations live under snapshots/ rather than beside messages.jsonl, so the retention this removes was the active file's and nothing else. Suite 69/69.
… the lineage A message can be stored more than once across a lineage: the active file may re-record one a sealed snapshot already holds, and sealing a generation can rewrite old tool results in place. Neither is a row that can simply be dropped — one is a second copy of one version, the other a second version of one message — so the route now merges them and keeps both. buildMessageIdentity() folds every occurrence into one identity and records the content versions it was written in; selectIdentityView() then picks the rows one request projects. The whole lineage shows each message once, at its first version; a generation shows that generation's own stored context. A row with no usable id is never keyed together with another. Two readings of "the same message" disagreed about one field. contentFingerprint() dropped history_artifact, because the newer generation's opener carries it while nothing about the message changed; equalOutsideContent() counted it, so whether a rewrite read as a declared trim depended on which row happened to carry the generation's annotation. It now ignores it too. Checked against the session store on this machine, the two comparisons disagree about none of the pairs compared — the change is behaviour-preserving on the data that exists, and it stops depending on annotation placement in future data. A turn's opener is decided over the whole lineage rather than the projected slice. Deciding it from the slice is what let a real steer become the first visible prompt of its own turn whenever the opener's generation was filtered away: it counted as dialogue instead of a redirect, and its turn was filed as neither prompted nor closed. Turns now report openerInView / openerOutOfView so those two facts stay apart — a view can hold a turn's steer without holding the row that opened it. stats.messagesAll counts logical messages over the reachable lineage instead of summing the per-file counts the catalog recorded, so a message stored twice is one message and a generation slice no longer shrinks the session total. The physical count moves to stats.messageRowsAll, and each generation reports the messages it stores as logicalMessageCount. The picker reads that, and the generation banner now says which generation is still growing — the picker can pin the live one, which the previous wording promised would never change.
verify-message-identity.mjs covers the merge, the two views, the four declared- trim predicates, the turn opener, and the three numbers the payload derives from them; mutate-message-identity.py breaks each one at a time. The declared-trim section hangs the generation's declaration on that generation's own opener row, which is where the store puts it. With it on the changed row instead, every negative case failed at the "equal outside content" predicate for a reason unrelated to the case under test — so two of the four predicates could be deleted and the section still passed. A positive control in the same shape keeps that from recurring. One mutation anchor did not do what its label says, which is worth recording: it replaced a precondition inside a comparison function with `if (false) return false;`. A never-taken branch does not disable the work below it, so the mutation was never observed and read as a live miss. Anchors belong on the line that decides the answer. mutate-steer.py gains the two ways a steer can come back: deciding the opener from the projected slice, and treating an opener the view does not hold as no opener at all. The generation picker's count and the banner sentence are new behaviour, so mutate-generation-counts.py breaks each: reading the catalog's per-file row count again, printing a count the option carries but the label omits, taking the whole-context number from the slice, and describing the live generation as a sealed snapshot. Three existing anchors are re-pointed rather than repaired, because the shipped lines they matched moved: the steer rule, the orphan filter that now attaches a per-generation count, and the orphan-matching line it sits next to.
…record Two record-addressing defects, both visible on stored sessions that keep more than one generation of the same conversation. An unmarked activity file was skipped A session's cross-generation information lives in exactly one place: its history-catalog.json, whose activeGeneration names the generation still taking writes. The lineage walk instead decided the active generation from the file alone, so an active file carrying no generation marker was read as generation 0 and the whole rest of the chain was dropped. One session in 139 catalogued sessions is affected: its chain resolved to a single file, and every earlier turn of that conversation was invisible. With the fallback in place the same chain resolves to generations 0 and 1 — 752 rows become 383 unique messages, 228 identical copies and 141 copies whose tool results were trimmed by the runtime, and 39 turn openers. The fallback only applies where it has to. A file that declares its generation is still taken at its word, so a catalog entry that disagrees with a file is still resolved in the file's favour, and a file that declares a generation other than the one sought is still refused. The catalog names which files to look at; it does not become the authority on what they contain. Rows and time spans were keyed by message, not by record The first column renders a record's index, and index is what a row means when it is scrolled to, pinned to, or brushed on the overview. Those three all went through the message-level id instead. A message split across records by a tool-result trim therefore resolved to whichever record happened to be first, so the scroll anchor, the overview selection and the time brush disagreed with each other and with the row the reader was looking at. Measured on that session, 228 messages have more than one record, so this is not a corner case. The overview now keys each span by the record it was built from, and its hit test returns the span rather than an id. That is what lets a click keep its message-level meaning — the same message's records highlight together, which is what selection is for — while hover and time brushing stay on the single record under the pointer. Selection is unchanged on purpose.
… and mutations A fallback that is never exercised resolves nothing, so both fixes ship with the evidence that they are load-bearing. verify-generations.mjs grows by 5 assertion groups covering the cases the fallback has to distinguish: an unmarked active file resolves to the catalogued generation, an unmarked sealed snapshot resolves to the generation it was sought at, a file that declares its generation still overrides the catalog, a fork still pointing at a generation other than the one sought is still refused, and a catalog with no active entry still emits the chain. 129 assertions, all passing. verify-message-identity.mjs already carries 16 mutations and client-check.mjs grows by 9: the compaction band and the row both key by record, the overview model pushes a record-keyed id, selection reads spans by record, and the hit test hands back a span so the click and the hover read different fields off it. The old assertion on the compaction band pinned an expression rather than the behaviour and is updated to pin the key. 186 assertions, all passing. mutate-generation-counts.py grows from 5 to 11 mutations and mutate-steer.py from 5 to 8, so both fixes are broken and caught by name: dropping the catalogued-generation fallback, letting a declaration override the catalog in the wrong direction, accepting a fork, and reverting each of the record-level keys back to the message-level id. All 72 guards pass.
…le text Two derived artifacts under the runtime's data dir, each with its own guard, neither wired to the route yet. They are here because the next change is going to need them, and because building them first is how their shapes get argued against real numbers instead of against an intention. The index: what a session's ledger looks like, laid out so reading a window or counting a kind costs a lookup rather than a re-parse of every generation the session has stored. The primary key is the record's own index, which is already dense and sorted on disk (measured gapless 1..36527 on a 44-generation session), so the file IS the sorted array a binary search wants. Four structures are built on load: message id to its records, generation to its records, kind to its count, and — the one that costs a sort — a side array of timestamps in time order. That last one is not optional: timestamps along the ledger are nearly but not quite monotonic (32 adjacent inversions in 25,428 rows on a real session, the largest a 141.8 s step backwards against a 98.9 h span), so bisecting the primary order would silently drop whatever fell in a gap. The guard holds a fixture with constructed inversions and shows, as a control, that bisecting the primary order on that same data loses records and the side array loses none. What a record does not carry is the bulk: the projected text fields are left out and read back from the source line on demand, which is the same move the existing lazy raw read already makes. The denylist is a denylist rather than an allowlist on purpose — a hand-copied allowlist lost eleven fields the page actually reads before it was measured, and a field this file forgot is a row that renders blank with nothing to say why. Two of them survive as booleans: whether there was error text, and the byte range of the line a record came from, which is what a search hit in a sealed generation is mapped back through. Nothing in either artifact is a machine path. A record names its file by an index into the manifest's own file table, and the resolved path is stripped before the manifest is written. Both are published with the manifest written last, so an interrupted write leaves a directory that reads as "no artifact" rather than as half of one. Neither is consumed unless its bytes match the digest the manifest was written with, its schema and projection versions are the ones this build speaks, and the source it was built from is still the source on disk. The search segment: the five fields a reader can search, stored once per sealed generation. Those five fields are measured at 36.7 MB for the worst session, against 8.6 MB for the index, so they are kept apart: the ordinary read never opens the search surface, and a search never pays for the index's own metadata twice. A sealed generation cannot change, which is what makes its segment something written once; the active generation is being appended to and is therefore never stored. The surface is the page's own search text under one stated normalisation — runs of whitespace become a single space, ends trimmed — applied to the surface and to the needle alike, so what the reader typed cannot decide the answer. One record is one line, which is not a formatting choice: the page joins the five fields with a newline, and a surface that kept that separator produced five lines per record, so a scan's line number stopped being the record ordinal its locators were numbered by and a hit resolved to whichever record happened to sit at that line. The guard pins that with an assertion on the ordinal rather than on the string, because string equality cannot see it — both sides shift together. And a scan may not cross a record boundary. One needle tested against each line alone; the guard constructs a needle that belongs to neither of two records and shows the joined blob would have matched it. All 76 guards pass: 84 assertions and 17 mutations on the index, 92 assertions and 17 mutations on the segments.
The page decided 「does this record contain the needle」 by scanning five fields of
the payload it happened to be holding. So 「只搜已加载部分」 was the contract rather
than a choice, and it missed everything the window did not hold. Search now asks
the server, which scans the same projection the payload was built from, and the
page asks a set.
Server
- /api/trajectory/search?session=&q=&generation= walks the lineage through the
one walk handleTrajectory uses, so both endpoints see the same records
- a hit is the record being displayed: hits resolve through the identity layer
and the view's version choice, never through stored lines, so a hit can never
name a row the ledger is not drawing
- the cap bounds what is sent, not what is counted: a thousand hits is a
thousand hits
- a needle shorter than two characters is a 400, not an empty answer
- indexes and one search segment per sealed generation are published after the
response, best-effort; the active generation is never published, because it is
still being written
Client
- recordSearchText is gone; matchesQuery asks the server's hit set
- an unanswered needle filters nothing and the count line says 正在搜索. Four
states are now distinguishable: 命中 N 条 / 未找到 / 搜索中 / 搜索失败.
「没搜到」 and 「没搜成」 used to be the same empty table
- one AbortController plus a sequence gate, so only the newest request writes
- a changed payload re-asks, because the answer names indexes of the payload
that was there before
- clearing the filters drops the answer with the needle
The overview strip now follows the answer instead of holding a predicate of its
own, and can reach a per-turn rule. Both are pinned, each by a mutation: the
strip previously kept its own copy of the whole decision, and it drifted the moment
the search moved to the server.
verify-raw-locators' release assertion was accepting a substring of the new
handler's `view.rows.length = 0;`. It now looks inside handleTrajectory and pins
that the `rows` being released is `view.rows`.
Tests: `node tests/run.mjs` on this tree -> 80/80 guards passed. New: verify-search-
endpoint 62 assertions, mutate-overview 2 mutations.
…pened
`text`, `thinking`, `arguments` and `result` were 59.1% of every response body, and the page held
all of it to draw a ledger row and — once in a while, for one record — four inspector tabs.
Measured over this machine's whole session store, projecting every record the way the route does:
text 8.9% thinking 13.2%
arguments 9.3% result 26.6% error 1.0%
Measured again over the twelve largest sessions, 6568 records, before and after the strip:
13.66 MB -> 5.57 MB, 59.2% removed
Server
- push() measures four presence flags, once, and the values stay on the records. The search
reads them in this process, so stripping at the projection would have broken search to save the
page memory; slimPayloadForWire() runs at the serialisation boundary instead, and the ?since=
delta is built from the stripped payload, so a poll carries no more bulk than the first load.
- /api/trajectory/record?session=&index=&generation= answers for one record. It re-projects the
single stored line that record came from and picks by rowPos — a line can expand into several
records (its text, each thinking block, each tool call), and a wrong position shows the reader
another block's reasoning as if it were this one.
- It does not answer with `kind`. Re-projecting one line reproduces every value but disagrees
about kind for an opener (measured: 1 of 6568; a steer row alone has no turn to have been
interrupted by). The page already has the record and its kind, and kind is the field a reader
would act on.
- `error` is gone. It was the same expression as `result`, set only when isError was already
true, so `isError || error` could never have disagreed with `isError`, and every failed tool
result was serialised twice.
- The row summary is one server-derived field. The page used to assemble it from
`summary || text || toolName || title`; over this store the `text` step decided nothing and the
other two decided it for 5 of 1287 records, all system rows.
- The line read and its digest check moved into readRecordRow(). Two endpoints need them, and two
copies is two answers to 「are these still the bytes that record was built from」.
Client
- the four tabs and the error block read the detail; availability comes from the flag
- 空答复 is decided by hasText === false, strictly. A record from a payload without the flag has
neither flag nor body, and calling that broken would mark it for no reason.
- the handoff fetches the detail alongside the raw message, and says so when the error text did
not arrive rather than printing 「失败」 with nothing after it
Also fixed, found while doing it
- make-payload.mjs picked the fixture session by mtime. It was not a rule, it was a timestamp,
and it had changed the answer without anyone deciding it should: the committed payload still
carried `raw` and `rawOmitted`, fields the projection stopped producing long ago, and every
guard reading it was asserting against a payload the server cannot produce. It now picks
deterministically and refuses to record without a tool error, a checkpoint, an open turn and a
body-less reply — the four the guards read it for.
- client-check asserted 「exactly two .innerHTML in the page」 under a label about jsonColorize.
A count fails the moment a third legitimate call is added and passes just as happily when one
of the two is deleted and the other becomes an injection. It is a property now.
Tests: `node tests/run.mjs` on this tree. New: verify-record-detail, 139 assertions, and
mutate-record-detail, 17 mutations each caught by name.
…he wrong turn
The cursor is `record.index` and a delta is cut at `index > since`. That rests on numbering only
ever growing — and the identity layer took that away. A message arriving that is a newer version
of an earlier one suppresses that earlier copy, and every record after it shifts down by one. A
tail cut at the old number then lands in the middle of the wrong turn: no error, no empty table,
just records in the wrong place.
The `since > upTo` fallback covers a page that has run PAST the server. It does not cover a
numbering that moved underneath it.
- Putting the etag in the cursor key does not work: the etag changes on every ordinary growth
(size, mtime, lineage), so every incremental poll would fall back to the whole payload and the
incremental path would stop existing.
- Forcing a whole payload on every generation change covers only one of the two ways numbering
moves. The other happens without any generation changing at all.
So the tail says what it assumed. `delta.basedOn` is the id of the record the cursor pointed at,
read while the tail is being cut — which walks those records anyway. The page compares it against
the record it holds at that index: same id, nothing before the cursor moved, the tail belongs to
this payload; different, the numbering moved, and the only honest answer is the whole payload.
Two things about the shape:
- The check runs BEFORE the merge and returns early. Checking afterwards would leave records in
the wrong turn with nothing to undo them.
- A delta with no `basedOn` is refused rather than trusted. This page cannot tell such a tail
from a correct one, and 「probably fine」 is the guess the rule exists to prevent.
`since === 0` passes: there is nothing before the cursor that could have moved.
Tests: `node tests/run.mjs` on this tree -> 82/82 guards passed. verify-incremental grew 5
assertions and mutate-incremental 5 mutations (24 total), each caught by name.
`tests/verify-time-range.mjs` is not in the default run: it needs a booted service and a
multi-generation session. That is also why nothing had run it since the session it was calibrated
against stopped existing. Booting the runtime against the live store and running it found two
failures, and neither was a fault in the range filter itself.
One was the wall-clock window, which was a literal: 23:50 to 00:20 on fixed October dates. Once the
session that belonged to was compacted away, both sides of the comparison the section exists to
make were 0, so it passed while comparing nothing, and only the assertion written to catch exactly
that noticed. A literal window is the one input in that file that can silently stop being evidence,
and nothing about the code can tell you when it has. The window is now derived from the payload:
the last row before a local midnight and the first row after it, five minutes of padding, floored
and ceiled to whole minutes. It is still required to cross a local midnight, since that is the part
that makes each bound a subtraction the machine's distance from Greenwich gets to corrupt.
- Reached from the rows rather than from a fixed width, because the reader steps away. On the
session served here the last row before the one usable midnight was 41 minutes early, so a
+/-10 minute window found nothing and reported the reader's habits as a fault in the
arithmetic. The first midnight inside the span turned out to have no rows within an hour on
either side, being in the middle of a 17 hour gap.
- If no midnight qualifies, the file fails by name and stops, rather than carrying on with
nothing chosen. A guard that limps past its own precondition ends up reporting the store's
habits as faults in the arithmetic, which is the failure this section just had.
The other was 清除筛选 no longer dropping the range in the function that clears it. The reset moved
into `resetBaseFilters()` and the assertion kept reading `clearFilters()`, so it went red on a
change that broke nothing — the same mistake the comment above it warns about, one indirection
later. It now follows the hop, and pins the hop itself: checking only the order would pass on a
清除筛选 that had stopped calling the reset at all, which is a filter the reader can neither see
nor turn off.
Neither edit is a relaxation. Both assertions hold the same thing they held before, and the second
gained a check it did not have. 151 assertions against the live service, green.
The teeth were then checked by hand, because this file cannot have a mutation control inside the
suite: `run.mjs` promises that nothing in it needs a running Mini App, and this one cannot be
checked at all without one — it derives its own fixture from `GET /api/trajectory`. Six mutations
against a throwaway copy of the package taken outside the tree, so no shipped byte was touched,
each required to fail with the assertion that owns the rule rather than a neighbour: the bound read
as UTC, the session-start subtraction dropped, one comparison in `recordInRange()` flipped,
`resetBaseFilters()` unhooked from `clearFilters()`, `clearTimeRange()` unhooked from
`resetBaseFilters()`, and the two clears in the wrong order. Six caught by name, the last three
producing exactly one named failure each. The client was byte-identical before and after.
`tests/README.md` claimed a control for this file, `mutate-time-range.py`, that has never existed
here — not in HEAD, not anywhere in the history. A file that names a control nobody can run is a
pointer with nothing behind it, so the claim is replaced by what is actually true, including that
the hand-check is a one-off and nothing re-runs it.
Tests: `node tests/run.mjs` on this tree -> 82/82 guards passed. Separately, against a booted
runtime on this machine's store: `node tests/verify-time-range.mjs <port>` -> 151 assertions, 0
failed, plus the six mutations above.
… on the way
A search was the one route that still rebuilt the whole lineage: `collectLineage`, then the identity
view, then `buildTrajectoryPayload` over every row of every generation, then a scan. The index and
the per-generation segments that already hold the searchable text were written on every request and
read by nothing. On the largest session measured on this machine that was 3.7 seconds to type a word.
before cold 3309.8 ms warm median 3667.3 ms warm max 3793.7 ms
after cold 1730.3 ms warm median 1689.0 ms warm max 1703.5 ms
Same session, same probe, artifacts directory emptied first. The median and the maximum landing on
top of each other is the signature of the artifact path answering every time; before, the spread was
the rebuild's. A live 20.4 MB session went 616 ms -> 208 ms. The remaining 1.69 s is the index
itself: 34.2 MB of records.jsonl, read and parsed per search. The projection is gone; the index
read is what is left, and sharding it per generation is the next thing and is not in here.
- `handleSearch` tries the artifacts before anything expensive. `indexStore.read` checks every
generation's source identity and the projection version, and the identity decisions baked into
the index are a pure function of exactly those — so no second intersection against the current
view is done. That extra step is the kind that silently disagrees with the thing it checks.
- The ACTIVE generation gets a segment too, which reverses §8.8.1 rule 5. It was right that such a
segment would be stale before it was written, and it still is; what changed is that nothing has
to trust it. `sameSource` reads the generation's own size, so an appended file invalidates its
own segment and only its own, and an untrusted artifact makes the search fall back. Without one
there would be nothing to scan on a session still being written — the case a reader is most
likely to search.
- A generation with no records gets an empty segment rather than none. "No segment" and "no
records" would otherwise report the same thing, and a session holding one empty generation
would fall back forever, for a generation that could never match anything.
- Missing anything falls back WHOLE. `total` is how many records in the session matched, so
answering from 44 of 45 generations reports a smaller number and says nothing about being wrong.
- A generation read short by our own cap now carries `truncated` in its source, and both
`sameSource` and `sameSources` compare it. `size` is the whole file whether or not the read
covered it, so before this a truncated generation's artifact was indistinguishable from a
complete one.
Two things this got wrong first, both worth more than the feature:
**The segments were being built from the index's records.** The index drops
`text`/`thinking`/`arguments`/`result` to stay small, and three of the five searchable fields are
in that set — so a segment built that way searches two of five, and a reader finds less with
nothing reporting it. Measured: needle "needle", 14 matches through the artifacts, 45 through the
rebuild. Both plausible. The surface is now built from the payload's records with the index
supplying only fileIdx and the byte range.
**And the guard was green throughout.** It compares the two paths needle for needle on a fixture,
which passed — because in that fixture every match happened to sit in `summary`. The guard now
asserts directly that a record's segment line contains its own `result` while the index does not,
and finds that record on purpose rather than hoping one is there.
Tests: `node tests/run.mjs` on this tree -> 82/82 guards passed. verify-search-endpoint 63 -> 91
assertions and mutate-search-endpoint 15 -> 18, mutate-search-segments 17 -> 18,
mutate-trajectory-index 17 -> 18, each caught by name. The five invariants are in the design note:
same answer as the rebuild, fall back whole, segments only for generations in the lineage, a
truncated generation is not trusted, and `total` is the count rather than the cap.
…ord index off that path
A hit has to answer with something the reader can be sent to: the record's ledger index, its
turn, its kind and its summary. Those lived in a second file read in step with the segment, and
resolving a hit meant going back to the session's whole record index — 34.2 MB of JSONL for the
largest session measured on this machine, parsed on every search.
Each segment line now carries that answer, so the search path reads the segments and nothing
else. The locators file goes with it: it said where each record came from, which nothing that
answers a search needs, and those fields are on the line now. A hit the scan named but the
reader cannot resolve falls back rather than being skipped, since `total` is how many records in
the session matched and a smaller number says nothing about being wrong.
The reader takes the whole scan in ONE call. Resolving one ordinal at a time walked the segment
from byte zero per hit: correct answers, and about eighteen gigabytes of walking for a common
needle, which measured SLOWER than the index read it replaced. Nothing about values can see that,
so the guard counts the walk instead — a boxed string with counted per-line operations, an upper
bound so a correct rewrite that walks some other way passes. Two more assertions cover the same
rule: no per-ordinal reader is exported for a caller to reach for by mistake, and the answers
come back in the order asked rather than the order they sit.
- search-segments.mjs: `readSegmentHit` -> `readSegmentHits`; `buildSegment` writes
`<compact json>\t<searchable text>` so a needle can never match into the answer; the scan
tests only what is after the TAB. `SEGMENT_SCHEMA_VERSION` 1 -> 2.
- server.mjs: `searchFromArtifacts` reads the segments and sorts by `index`.
`collectSearchHitsFromArtifacts` is gone — it existed to let the artifact path use the index.
- trajectory-index.mjs is untouched. The index is still written; what to do about a subsystem
nothing reads is a separate decision and is not taken here.
Also adds the root `plugin.json`, which `sync-sop.py classify` has been listing as SHIP — the
host's `mcode.miniApp` entry pointer — and which was in the working tree and shipping to readers
while never having been added to the repository. It was never deleted either: `git log --all` on
that path returns zero commits, so "the whole directory installs" was true of a file git did not
track. That is also why every projection refused until now.
Measured on the largest session on this machine (189.1 MB, 45 generations, 25,427 messages,
36,525 records), same request, artifact directory emptied first:
baseline cold 3309.8 ms warm median 3667.3 ms warm max 3793.7 ms
this cold 1439.7 ms warm median 1416.6 ms warm max 1432.6 ms
Six needles compared the artifact path against the rebuild on real data: identical bodies and
identical totals (45 / 14,092 / 9,103 / 12,770 / 3,604 / 6,227). Of the 1,416.6 ms that remains,
reading the 44.4 MB of surfaces costs 37.9 ms, hashing them 82.7 ms, and scanning them 20.7 ms;
the rest is the lineage walk parsing every generation's rows, which this path does not use and
which is the next thing to look at.
Tests: `node tests/run.mjs` on this tree -> 82/82 guards passed. verify-search-segments 116 -> 122
assertions and mutate-search-segments 19 -> 22, verify-search-endpoint 93 -> 96 and
mutate-search-endpoint 17 -> 19, all caught by name.
Set a time range and 「追加 N」 disappears from the message card and the turn card. It is still on the kind chip below them, and it is still correct for the whole session — the whole-session cards read the server's stats, and these two read the same numbers re-scoped to the range. Both readings were right and nothing said so. The cause is a shape that lives in two processes. `scopedStats()` builds its own literal rather than reading the server's, the server declares `steers: 0`, and this copy did not — while the loop below still did `stats.steers += 1`. `undefined + 1` is NaN, NaN is sticky, and `isCount()` refuses a non-finite number, so both readers of that field dropped the term silently. It stayed invisible because the guard comparing scopedStats against the server numbers field by field carries a hand-written list, and `steers` was not in it. A hand-written list can only catch the fields someone remembered to write down. So the field is added to that list — the direct check for this field — and the new assertion reads both halves off the function itself, which is what stops the next one whatever it is called. It also covers the nested shape: `stats.tokens.input` needs `tokens` declared, and the parent name is what gets compared. Read points, all of them: `stats.steers` is read in exactly two places, both inside `renderKpi()`. `kpiStats()` has three call sites; the other two read only `messages` and `turnsWithPrompt`, so their numbers do not move. Tests: `node tests/run.mjs` on this tree -> 82/82 guards passed. mutate-turn-breakdown gained one mutation — delete the field from the literal — caught by name. client-check gains one static assertion, which is where it lives rather than in the range guard: that one needs a booted runtime, and run.mjs promises nothing in the suite needs one. Found by a retirement audit rather than by a failing guard, which is the first time that has been the discovery route here.
总览条是下面那张表的画,但它在 spanPassesFilter() 前面挡了一个局部标志, 只列了五个轴里的两个(类型、搜索)。于是设了时间区间、或者只勾了一条异常规则时, 那个标志说「没在筛选」,条带继续画着下面那张表已经丢掉的记录 —— 读者看到的是「筛选了但什么都没变」。 标志是删掉,不是补全:一份列举轴的清单就是一份会漂移的清单。笔选的例外不由 「这个轴没列进去」表达,而是由 spanPassesFilter 传 null 表达。 可见行为因此改变,且是往对的方向:设区间或勾异常时,总览条会跟着变窄。 本地实测(189.1 MB / 25,427 条 / 45 代的那一场,872×900): - 点「今天」,区间内 0 条:指标读到 0 条消息 / 0 轮,#overview-lanes 的 outerHTML 变成 73 字节、零个子节点。改之前这里仍是三条满带。 - 点「工具失败」756 条:#overview-lanes 是 70,177 字符 ≈ 756 个 span, 只有工具道是红的。改之前这里仍是三条满带。 守卫补的是渲染,不是谓词。verify-overview.mjs 原来有 103 条断言、19 处引用 spanPassesFilter,但 renderOverview() 从没被执行过 —— 沙箱里没有 DOM。 这就是这个缺陷活下来的原因:谓词一直是对的,错的是调用方不再问。 所以给沙箱补上 createElement 和一个最小节点桩,把渲染跑起来, 断言写成读者会说的那句话:总览条是下面那张表的画,同样的记录、同样的顺序, 在每一种筛选下都是。103 → 127 条,每种都把行号写死再和 visibleEntries() 对比, 否则两边同时为空也会「通过」。 mutate-overview.py 加第三条变异,把那个局部标志原样写回去(isFiltering() 砍掉三轴的那种写法),按名抓住,报的是 strip under a time range。 套件 82/82。
样式表里有 159 条 class 选择器,十一个在 <style> 之外零命中:btn-primary、btn-text、 bar-success、chipbar、chips、chip 家族(含 chip-mark / is-on)、turn-meta .num、mono, 以及 legend —— 最后这个原始扫描漏了,手扫也把它读成活的,因为它夹在一堆活规则中间。 删完 148 条,少 94 行。 删规则之前先查了变量:btn-primary 是 --mcode-primary-action-bg 和 -text 的唯一消费者, 而这两个变量在三个主题块里各声明一次。只删规则不动变量就是新造六行死声明 —— 下一次扫死 CSS 的人会看见它们,然后不信这次扫描。删死代码做一半比不做更糟, 因为它把一个可核对的结论换成一个不可核对的。 守卫写的是性质不是名单(tests/verify-css.mjs,42 条): 1. <style> 里每条 class 选择器,在 <style> 之外至少有一个消费者。 2. 每个自定义属性都至少被 var() 读一次。 按名字写死的清单只能挡住同一个名字被加回来,而没人检查的死规则活不过两次重构。 第二条不是设想的,它就是第一条删 btn-primary 时撞上的那六行。 这个守卫自己出过一次错,错法值得记。第一版把「拼接前缀」当成无条件豁免:只要脚本里有 'turn-' + 这类写法,前缀下所有 class 永远算有消费者。后果是删掉 .turn-meta 的唯一 消费者之后,规则看着还是活的,守卫还是绿的 —— mutate-css.py 的第二条变异直接存活。 修法不是删变异,是把豁免收紧:前缀只能覆盖它能点出名字的那个类。生成器取值来自固定 字面量表(' pill-' + tone,kindTone() 返回九个之一),所以 pill-user 之所以活是因为 'user' 作为字面量存在,这不需要跟数据流就能查。前缀点不出后缀,就什么都不覆盖。 mutate-css.py 三条变异,三个方向都按名捕获:给没人建的标记加规则;轮次头去掉副行而 规则留下;主题块里声明一个没人读的属性。 套件 84/84。浏览器实测:页面结构与删除前逐项一致,无塌陷、无位移。
删掉 formatCompact、turnContext、turnContextSuffix、buildBlock。它们在产品路径上都没有
调用者;turnContext 只被 turnContextSuffix 调用,而 turnContextSuffix 只被测试调用。
turnContextParts 和 turnSubNode 是活的,没动 —— 它们是同一条链的上游。
collectSessionEntries 改判,不删:它有八个读者,全是测试,而 tests/ 随包分发,
所以读者和它一起装到读者机器上,它不是悬空的。「产品路径没有读者」和「没有读者」
是两件事。删它要先迁移八个测试,那是测试基础设施重构,不是退役。
两样东西必须跟着搬,不能跟着删:
一是注释。turnContext 头上那段讲的是产品理由 —— 两个不相交的「5」并排会被读成同一个数
数了两遍,另有 是分界。turnSubNode 还在实现同一个分界,所以注释搬了过去。
二是断言。context group is marked as a separate population 和 numbers before 另有 sum to
the headline 原来跑在 turnContextSuffix 上 —— 一个没有任何人渲染的呈现器。这种断言会在
被渲染的那一行坏掉时依然通过,而那正是它存在的理由。改指到 turnSubNode,并且真的跑它:
沙箱只有 { console },所以给 el 和 document.createTextNode 补了最小桩,把渲染出来的文本
从树上读回来。顺带修好一处 —— 旧断言传的是 compactionTurns,而 turnContextParts 读的是
compactions,所以 压缩 那一项从来没进过断言;它过了,但它没在测自己注释里说的那件事。
mutate-record-detail.py 里那条变异的替换文本原本调用 buildBlock。删掉它会把这条控制变成
崩溃(渲染时 ReferenceError)而不是按名失败,而报崩溃的控制等于没报。替换文本改用活代码
重写,缺陷不变:结果页直接渲染 record.result,瘦身之后它永远是空的。17 条变异仍全部按名捕获。
mutate-turn-breakdown.py 的锚点跟着分隔符搬到 turnSubNode,回归本身没变。
守卫写的是性质:内联脚本里声明的每个函数,都在自己的声明之外出现过(tests/verify-dead-code.mjs)。
不是名单 —— 名单只能挡住那三个被加回来。这条规则在这份文件上是准的:批次之前 230 个声明、
4 个无读者,之后 226 个、0 个。
规则定下来之前错了三次,三次全是误报。只认调用会把十三个 addEventListener 的事件处理器
全报成死的,所以是「出现过」不是「被调用过」;没剔注释会把散文里的名字当读者;
为不让页面挂载而切掉的启动块里正好有 init() 的唯一一次调用。三处都写进了守卫的头注释。
另外断言了规则自己的前提:顶层函数缩进恰好 6 空格。一次重新格式化就能让规则悄悄不再
覆盖大部分文件,而那是最像成功的失败。
三条变异按名捕获,其中第三条「把唯一的引用只留在注释里」专治「规则没剔注释」那个错误方向。
范围只到 index.html;server.mjs 有同形状的东西,但它的导出被八个随包分发的测试读着。
套件 86/86。
磁盘上的索引目录是死的,投影那一步是活的 —— 所以这次退役的不是「一个索引」,而是
一份没人读的产物,加上一次每次发布都要重算的完整记录投影。
server.mjs 从索引模块导入九个名字,六个零读者。剩下三个里有一个例外:索引行的返回值
在段构建时被当场读走,每段行取它的 fileIdx / byteStart / byteEnd。把它连同模块一起删掉,
搜索就再也拿不到记录落在文件里的位置 —— 那正是「按记录取详情」端点的输入。
因此投影保留,但收窄成只产那三个字段,搬进 search-segments.mjs(这三个字段就是段行
JSON 前缀的一部分,搬过去之后段行只有一个来源)。trajectory-index.mjs 整个删除。
「无定位得到 -1/-1/-1,且不得误归文件 0」这条语义原样保留。它此前分散在两层,各守一半:
段守卫只管「完全不带定位字段的输入」的归一化,根本不调用投影;投影层那条只有一处守着,
而它马上要跟着模块一起没了 —— 所以搬到了新守卫里,并且真的跑投影函数,不是抄一遍它的
匹配表达式。
守卫与变异:
- tests/verify-locator-rows.mjs(18 条):顺序、形状、fileIdx、字节范围、同名文件不得按
basename 匹配、无定位不得变成文件 0、邻居保住自己的、空输入与空 locator map
- tests/mutate-locator-rows.py(4 条):四个方向各一条,全按名捕获
- verify-search-endpoint.mjs 96 → 97 条
重写了一条断言。原来它说的是「这条搜索路径不读索引」,而写索引的调用在同一个函数里,
所以它要靠作用域才成立。现在索引两边都没有了,规则就写成它真正是的东西:整个服务里
没有任何记录索引。断言之前先剔注释 —— 服务端要解释索引去哪了,而一句提到它的解释恰好会
让「没有索引」这条规则失败,那么唯一的写法就是什么都不写。
这条断言原本是三个名字的名单,而名单只能挡住名单上的东西。补了一条按性质的:产物根目录下
只允许存在一个子目录(段所在的那个)。用一个 token 名单抓不到的名字注入验证过,它按名失败
—— 一个用新名字重建的索引会通过名单,被这条抓住。
mutate-search-endpoint.py 补到 20 条,新增的那条用活代码重建被退役的形状:段旁边再写一份
产物,没有任何人读它。这不是把模块加回来,是有人真会写出来的回归形状。
顺带修好一段已经过期的注释:它说索引还在用、并且「是它标出每条记录的 index、turn 和 kind」。
索引没有了,turn 来自 payload 的 turn 表,投影只出文件与字节范围。
存量目录留着不动:停写让新增写入归零,不改变已经落盘的文件(实测每个会话每个视图 8.6 MB),
回收是迁移,不是停止写入的同一步。已确认全仓库没有任何删除或修剪逻辑碰它,所以留着是安全的。
套件 86/86。
横幅写着「正在重试」,而它能被收掉的唯一条件是「服务端回了一个带 body」—— 304 不算。 而 304 正是页面静止时每一次轮询的答案:cacheKey 只由 dir|sizeBytes|lastActiveAt 决定, 会话没动就是同一个 tag,服务端必然回 304。于是**一次瞬时的连接抖动 + 一个安静的会话 = 横幅永久停留**,并且一直宣称自己在重试。点「刷新」能收掉它,但那只是因为手动刷新丢掉了 validator,强制拿了一次完整 body。 顺带查出同形的第二条:会话列表有完全一样的 304 早退,以及一个**写了三处、读了零处**的标志。 列表读取失败对读者完全不可见。 改动: - 两个散落的布尔(state.stale、state.sessionsError)换成这一层自己的 freshness 模型, 唯一写者 noteFresh()。原来 state.stale 有三个写点,现在每个字段只有一个。 - **304 也算新鲜** —— 轨迹和会话列表两条路都改了。 - 认不出的失败拆成两类:「与本地服务的连接中断」和「服务端返回了无法读取的内容」。 原来四种错误码加上这两类全部塌缩成同一句「更新失败」,页面上不留计数也不打日志, 一次抖动和一次持续故障事后无法区分。 - 连续失败从第二次起显示次数与来源(轨迹 / 会话列表)。第一次不给这句解释,因为一次抖动 会在一个轮询内自己消失。 - fetchSessions 的 HTTP 失败原本把 status 扔掉了,所以任何非 2xx 都会被归成「断线」。 **修复不许变成另一种代价**:render() 重建整个账本,安静会话每 8 秒一个 304。 noteFresh() 返回「状态是否真的动了」,只有动了才渲染。这条有断言,不是靠自觉。 四问在 .research 的退役审计 §11,含逐个读点表。 守卫 tests/verify-freshness.mjs(27 条):把交付的内联脚本原样载入沙箱**真跑**, 断言写成场景而不是符号清单 —— 失败后接 304 / 接 200 / 连抖三次 / 断线 vs 坏响应 / 只有列表失败 / 五个 304 之后渲染次数为 0。 这一层此前没有任何守卫(state.stale、sessionsError、stale-bar 在 tests/ 里的匹配数是 0), 这正是四个缺陷能活下来的原因。 变异 tests/mutate-freshness.py(5 条)全按名捕获:把 304 当成什么都没发生;失败列表只记不看; 恢复不清计数;断线被报成服务端的问题;每个 304 都重渲染。 另加一条反方向的用例 —— 只加一行注释必须仍然全绿,否则守卫读的是散文不是行为。 套件 88/88。
最坏会话(36,525 条)下这条带子建 36,525 个 `.ov-span`,每个至少 2px 宽,于是 ~36,000 个是
互相压在一起的 —— 872px 的带子上只有约 436 格,每格读者看到的是「最后画上去的那条」。
每次轮询带 body 的重建,浏览器实测 3,937 ms,轮询间隔 2.5 秒。
改动:按**像素列**建节点,一列一条车道取最后一个覆盖它的记录 —— 也就是原来叠放时每个像素上
读者真正看到的那个值。所以**每列的逻辑答案和以前是同一个**。但画面不是逐像素相同的:
旧的盒子最小 2px 且带最多 1px 的间隙,新的是 1px、间隙 0。所以是**更锐**,不是**一样**。
颜色、透明度、车道、`data-kind` 全部仍然来自同一批 CSS 规则:这次只决定**有多少个节点**
和它们**站在哪**,调色板没有第二个来源。这也是没有选 canvas 的原因。
交互一行都没动,因为它们本来就不走 DOM:
笔刷区间 / 滚轮缩放 overviewFractionAt → overviewTimeAt → nearestSpanAt
区间是否含某记录 recordInSelection,读 overviewSpansById 这个 Map
`overviewSpansById` 仍然为**每一条**建条目 —— 区间判定要用。只有画被聚合了。
悬停和点击改了:以前 `spanAt()` 用 `document.elementFromPoint` 拿指针下的节点,
在每像素 42 条时它已经是随意的(返回最后画上去的那条);聚合之后它变成**只有列的赢家
有节点**,其余记录根本够不着。现在车道仍然从 DOM 读(车道在 CSS 里,14px 的间距不该在 JS
里抄一份),而记录从**完整模型**里取 —— 指针附近在时间上最近的那条。悬停因此比以前精确,
不是变差。
选中记录与悬停记录各多一个节点,压在最上层:列是一个范围,而「这一条」不是;
选中的那条还可能落在当前视口之外,那更是列表达不了的。
条带没被排版过时退回「每记录一列」,也就是改动前的行为 —— 这不是防御性写法,而是这个函数
不该把画面变得更糟的保证。
实测(同一份探针、同一台机器、同一个桩):
| 记录数 | renderOverview 改前 | 改后 | 节点改前 | 节点改后 |
| ---: | ---: | ---: | ---: | ---: |
| 5,000 | 36.3 ms | 34.9 ms | 5,000 | 2,559 |
| 36,525 | 546.5 ms | 192.5 ms | 36,525 | 2,616 |
节点数在 20,000 条就饱和(872px × 3 车道 = 2,616),与记录数无关。outerHTML 从约 2.7 MB
降到约 190 KB。
**收益只算一遍**:去掉的是 DOM,不是那次遍历。改后 192.5 ms 里 overviewModel() 自己占
78.6 ms(41%),其余约 114 ms 是 36,525 次循环本身。「每记录一个节点」的成本归零了,
「每记录走一遍」的成本原样还在。1,000 条那一档变慢 2 ms(聚合的常数项),那一档本来也不痛。
四问在退役审计 §12,逐个读点列了出来。其中一条是**新断言当场抓到了聚合自己造出来的缺陷**:
3 列放 5 条记录时中间那条一列都没占到,从条带上整个消失 —— 改之前它是有节点的,丢不掉。
所以承诺按两种情形分开写:条带宽于记录数时每条独占一列、条带等于列表(断言原样保留);
条带窄于记录数时承诺「画的每条都是列表留下的」且「覆盖记录走到的每一列」。
守卫 verify-overview.mjs 127 → 146 条。stripIds() 改为去重 —— 一条记录可以占住多列,
而「每条记录一个节点」在每像素 42 条时本来就不是读者能验证的话。
mutate-overview.py 3 → 8 条,全按名捕获。原有三条变异锚在 spanPassesFilter 那行,聚合发生在
它之后,锚点原样继续解析。
其中一条是被验收评审推翻之后才写的:第一版守卫直接调 markOverviewHover('g3'),注入 id,
所以它绕过了必须「发现」这个 id 的那条路 —— 而那条路当时是坏的。它是绿的,缺陷也是绿的。
现在这一段驱动真实的 pointermove,让 DOM 按浏览器的方式答一个**列赢家**,再要求模型答出
**那条输掉所有列的记录**。为此桩补了 classList:没有它,命中测试在测试里根本无法成立,
这也正是这个缺陷能活下来的原因。
套件 88/88。
聚合把「每条记录一个节点」变成「每像素列一个节点」之后,只有**占到过某一列**的记录在 DOM 里有节点。
而命中测试当时是 `document.elementFromPoint` 往上走到 `.ov-span`,读它的 `data-record-id`,再查 Map ——
于是**输掉所有列的记录既点不到也悬停不到**,而上一批为它们写的悬停标记因此永远不会亮。那条标记是死代码,
它写的场景它自己触发不了。
- **车道从 DOM 读**:14px 的车道间距住在样式表里,指针的 y 是唯一诚实的办法。在 JS 里抄第二份,
意味着以后改车道要记得改两个地方,而记不得的那一次会安静地指错行。
- **记录从完整模型取**:`nearestSpanAt(time, lane)` 在模型里按时间最近找,可选地只看某一条车道。
于是答案是**离指针最近的那条记录**,而不是最后画上去的那条 —��� 两个问题读者问的都是这个。
指针不在任何节点上时仍然返回 `null`,不做吸附。
顺带查出一条真缺陷并改掉:`mutate-generation-counts.py` 锚的那一行就是被这次改动删掉的那行,
锚点失效 → 控制 `raise SystemExit` → 整套件掉到 87/88。锚点只能改锚点,于是改锚到新代码形状上:
`return nearestSpanAt(overviewTimeAt(overviewFractionAt(clientX)), lane);`(全文件唯一,另一处
`nearestSpanAt` 在滚轮手势里,不带 lane)。**没有**改锚到「读 DOM 那个 id」——那是 `mutate-overview.py`
里已经有的那条变异,重复一条会让两个控制各说一次同一件事,而这条变异的名字声称的是返回值的形状,
不是解析路径。
改锚时发现守卫有个缺口,而且缺口比想象的大:
- `client-check.mjs` 那条断言是**读源码文本**的,它原来的两个拒绝项是「不许出现
`getAttribute('data-record-id')`」和「不许出现 `return <词>.id`」。第一条挡住从 DOM 解析,
第二条挡住直接返回 `.record`。但**按语法枚举返回值不会结束** —— `recordKey(hit.record)` 和
`hit.id` 都返回一个键、都保留模型解析、都不含那两样东西。
- 所以这条性质搬到了 `verify-overview.mjs`:那里的 sandbox 是 `vm.runInContext` **跑交付的脚本**,
`sandbox.spanAt()` 可以直接调。断言改成**问它返回什么**,而不是读它怎么写的。
- 再往前一步:只问**形状**也是不够的。`{ id: hit.id, record: hit.record }` 带齐了形状检查看的每个
字段、也报对了记录号,而页面后面的一切在它上面都能正常工作 —— 它是**新对象**,不是命中测试找到的那个段。
点击和悬停拿到的就是这个值,所以要成立的是 `directHit === crowded`(`crowded` 就是
`overviewCache.model.spans` 里那一个,`nearestSpanAt` 按引用返回它)。
两条位置上的讲究都是被变异控制教出来的:
- 断言必须排在 handler 用例**之前**。返回键的改动同样会弄坏后面那几个悬停断言;排在后面,
这条变异会归因到先失败的那条,再也拿不到自己的名字。
- 两处读取都要过同一个 guard。第一版直接写 `directHit.record.id`,命中测试答 null 时抛 TypeError,
两条变异立刻变成 `UNNAMED` —— 崩溃不命名规则,这个项目把它算成比红断言更弱的结果。
守卫与变异:
- `tests/verify-overview.mjs` 146 → **149**;桩补了 `classList`、`style.getPropertyValue`、
track 与 lanes 分开的宽度、场景前清 `overviewDrag`。最后一项原来缺得静悄悄:
前一个场景留下的 brush 会让 pointermove 直接 return,悬停断言全部测不到东西
- `tests/mutate-overview.py` 8 → **10**,10/10 按名捕获。新的两条是「返回行键」和「返回假壳」,
后者**只**被身份断言抓住
- `tests/client-check.mjs` 206 条不变,那条断言的注释改成明说「这不是完整性质」并指向真正拥有它的那条
- `tests/mutate-generation-counts.py` 12 条全按名捕获
套件 88/88。
这两个字段写进每一行段记录的 JSON 前缀,**shipped 搜索路径里没有任何一处把它们读回来**:
`readSegmentHits` 只取 5 个字段(index / turnIndex / kind / summary / generation)后丢掉其余,
而真正需要定位符的两个端点 `/api/trajectory/record` 与 `/raw` 读的是内存里的 lineage 缓存
`cached.raws`,压根不碰段文件。实测代价:真实段面 **4.36%**,每行 49 字节
(1,177 行、1.3 MB 的样本;49 × 1,177 = 57,673 字节)。
它们当初是按一条**对但没走完**的规则进来的:段行携带「一条命中回答所需的一切」,于是命中应当自足。
打开命中走的是内存缓存那条路,缓存冷时直接 `trajectory_not_cached`,而**轨迹路由**才会
`loadParsedRows`(按 size + mtime 建键)重新解析并回填 —— 承诺从没兑现,字段一直在全额付钱。
补一条来路事实,免得被当成抄来的:**定位符是这个 app 自己算的**。`parseMessagesJsonl` 解析时
记下每行的字节区间,拼成 `{ filePath, start, end, digest }`;代码里没有任何地方从会话文件读一个
叫 locator 的字段。同一个 locator 里的 `digest` **是被读的**(`buildMessageIdentity` 用它做身份
指纹),只是没跟着进段文件。所以退役的是「位置」那部分,不是整个 locator。
改动:
- `SEGMENT_SCHEMA_VERSION` 2 → 3。已落盘的段**全部失效一次**,每个会话首次搜索重建一次。
这是本次唯一对外可见的行为变化,且是单部署下的本机重算
- `locatorRowsFromPayload` → `fileIdxRowsFromPayload`,只返回 `fileIdx`。**函数连名一起改** ——
它不再产出 locator,名字声称的东西变了而名字不变,正是这个文件一直在还的那种债
- `fileIdx` 留着:它是唯一的分代分组键(`located.fileIdx !== fileIdx`)。「无定位得到 -1,且
不得误归文件 0」这条语义原样保留,且它本来就只住在投影层 —— 段守卫管的是「完全不带字段的输入」
的归一化,根本不调用投影,所以如果投影把无定位的记录修成文件 0,段那边 122 条断言会全绿
- 删掉一个零引用的 typedef
两条「它没了」的断言都写成**能被真的打红的形状** —— 不是「输出里没有这两个字段」,而是
「**输入里带着**这两个字段、输出里没有」。只写前者的话,退役之前它也会绿,等于什么都没钉。
退役有两扇门,各配一条变异:
- `fileIdxRowsFromPayload` 那一扇(`tests/mutate-file-index-rows.py`):变异把字节范围写回投影的行
- `buildSegment` 那一扇(`tests/mutate-search-segments.py`):变异把两个字段写回段行。
**`buildSegment` 照抄记录上被给的东西**,所以只停投影那一扇停不住;只盯 `buildSegment` 那扇,
上游哪天又开始提供字节范围,它就悄悄回来了
守卫与变异:
- `tests/verify-locator-rows.mjs` → **`tests/verify-file-index-rows.mjs`**(137 行,16 条):
顺序、形状、fileIdx、同名文件不得按 basename 匹配、无定位不得变成文件 0、邻居保住自己的、
空输入与空 locator map、无 locator map 与无源文件同义、path 重复时先到先得
- `tests/mutate-locator-rows.py` → **`tests/mutate-file-index-rows.py`**(143 行,4 条),全按名捕获。
原来那条「字节范围被丢掉」的变异随主体一起没了,换成上面那条反向的
- `tests/verify-search-segments.mjs` 121 条,`tests/verify-search-endpoint.mjs` 97 条(不变)
- `tests/mutate-search-segments.py` 22 → **23** 条,23/23 按名捕获
- `tests/mutate-locator-rows.py` 与 `tests/verify-locator-rows.mjs` 删除(改名,不是退役)
四问与逐个读点在退役审计的 §17:段面字节数(查过,没有任何守卫承诺段面大小)、
schema 版本不匹配的回退(已有覆盖)、`readSegmentHits` 承诺的 5 个字段(不变,新增断言钉住)。
套件 88/88。
renderOverview 有八个调用点,其中七个不动模型的任何输入 —— 筛选、选中、刷选、
滚轮、平移(含 60 ms 定时器)、切会话都带着同一批行和同一个轴重进。建模在
36,525 条记录上占 186.3 ms 里的 59.6 ms(32%),每帧还要 churn 约 73k 个对象。
所以这不是一个新缓存加一套失效策略,是把模型的两个输入比一遍:
- state.flat 全文件只有一个写者(flattenTurns),且从不原地修改,所以新数组
= 新行,同一数组 = 同一批行。内容比较要多走一趟每条记录,而那正是要省掉的
那一趟。
- state.actualDuration 是另一个输入,它自己的 setter 在值未变时提前返回。
两个键缺一不可。只键行数会把另一个轴上的模型留下来,总览条会把每条记录画在
读者刚关掉的那个轴的位置上 —— 所以第二条变异就是专门去掉轴的键。
它一开始存活了,而原因不是守卫漏了,是守卫自己的前一步断言把另一个数组留在了
缓存里,于是「行」的比较也 miss、重建照常发生,轴那一步观察到的重建什么也证明
不了。补一行把 MIXED 放回缓存之后才死。同一个文件里相邻两条断言,会让第三条
断言通过它本该抓不住的缺陷,这是本次唯一一处需要停下来想的地方。
口径不变:总览条上每个数、每个位置都不动,变的只是同一批 span 对象不再被重复
构造。增量内存约等于零 —— overviewCache.model 本来就活着,缓存是「不再重建」,
不是「多留一份」。
守卫 149 -> 162(数重建次数,不断言字段存在);变异 10 -> 12,逐条按名捕获。
它被跳过的唯一原因是输入。断言要 >1000 条消息、要跨本地午夜,而能拿到的会话
要么是「这台机器恰好有什么」,要么是 60 行的 fixture。三条路都不行:塞进默认
集合等于发红;把数字改到 fixture 够得着,时区算术整个不测;提交一个几 MB 的
真实会话,等于把会话内容打进包里。
现在的做法是自己造会话,交给服务端自己的 buildTrajectoryPayload —— 也就是
/api/trajectory 路由调的那个导出函数。合成的是对话,stats 仍然由被测代码算
出来;第 3 节问的还是「页面的重新推导与服务端的数字是否一致」,两边都还是出厂
实现。
造出来的会话刻意做成让比较咬得动,而不只是能通过:被比较的字段必须非零,否则
0 === 0 全绿而什么也没测。所以 prompt 轮、continuation 轮、未收尾轮、一轮向读者
提问、steers、失败的工具调用、五个 token 计数器都是设计出来的。合成后 compactions
与 compactionTurns 为零,而 compactionTurns === 0 是故意断言的:裸 checkpoint 轮
会让两边在推导不同的人群,而不是对同一份人群有分歧。
起始时刻挂在本地日历上而不是一个 UTC 瞬时值 —— 被测的就是本地午夜,一个固定的
UTC 起点会让它在每台别的机器上落到别处,这个文件就变成在量存储的时区。还会避开
跨夏令时的那几天,因为墙钟动了而 relativeMs 没动时,被比较的两个读数会分家。
两个不显然、第一版都写错的形状:
- closed 不是 stop reason。服务端是从 kind === 'reply' 的记录推出 closed 的,
而 reply 由字面量 'stop' 赋值。写成 'endTurn' 会投影成 narration,于是每一轮
都静默保持 open —— openTurns 出来是 275/275,这个形状看起来很健康,实际什么
都没测。
- 一轮只有在工具结果之后再来一条 assistant 行时才算收尾;一条同时带文本和工具
调用的 assistant 行不是 reply。
新增 mutate-time-range.py 八条,全部按名捕获。其中六条是这份守卫在默认集合之外
的那段时间里手工验过的变异 —— 那是一次性结果,没有任何东西会重跑它,所以后来的
改动可以在这个目录里全部通过而这份文件无人验证。现在它们在这里跑。另有一条反向
用例:只加注释必须不改变任何判定。
152 条断言。run.mjs 的 SKIP 集合随之删除。
存储里一直有 parent_session_id 这一列,而服务端的会话查询一直只取 title 和
session_kind,所以运行时派生的每一次子代理运行,在列表里都和一个顶层会话长得一模一样。
唯一发到客户端的 sessionKind 在 index.html 里出现 0 次——字段一直通着,没有人读它。
链路是四段,缺任何一段都是同一个静默结果:查询取列 → 行映射成 parent(空串算没有)
→ 挂到 descriptor → stripInternalFields 以 ?? null 过闸 → 客户端按字段决定标不标。
所以四段各有一条断言,而不是一条:只看其中一段的实现,另一段被切掉时照样绿。
为什么只标「子会话」而不写父会话的标题:分支一定比派生它的会话新,而列表按最近活动
取前 30 个,父会话通常已经掉出这个窗口。写标题就是一个「读者最想要答案的时候正好失败」
的查询,而静默失败的查询最后会让界面声称一个它看不见的东西。
它是一个标注而不是筛选器:不写任何 state,所以不存在「第二个筛选器对哪些记录在屏有
不同看法」这一类联动异常。
口径不变:纯文本后缀,不改计数、不改统计、不改「最近 N 个」的截断。但它改了
/api/sessions 响应体的字段集合,而此前没有任何守卫钉住那个集合——stripInternalFields
在整个 tests/ 里 0 命中。所以这次补的是一块现在还没有的地板,而不是守住一个已有的数:
- verify-list-cache.mjs 18 -> 23:四段链路各一条,外加客户端那半读的是去掉注释的
源码——sessionOptionLabel 上方那段注释就写着「子会话」,对着原始文件断言会被它自己
要抓的东西满足。
- client-check.mjs 206 -> 212:把 sessionOptionLabel 跑起来,验身份而不是形状。两个
只差 sessionParentId 的会话,标签必须只差一个标注。一个无条件加标注的版本能通过
任何「里面有没有子会话」的检查,过不了这一条。
- mutate-list-cache-ttl.py 12 -> 17:四段链路各切一处,加上「标注加到每个会话上」
这个看起来像对、实际上把每个顶层会话都说成子代理运行的方向。
评审给的是 not acceptable,两条都成立,都已修。 **一、`/api/sessions` 的 ETag 没有覆盖它新读的那个字段。** `sessionsValidator()` 的材料是 dir | sizeBytes | lastActiveAt | title | kind,不含 parent; 而响应体在组装时已经读了 `meta.parent`。body 读一个字段而 validator 不哈希它,就是:parent 变了 而目录统计、标题、kind、limit 都没动时 tag 不动,轮询的客户端拿到 304,不带 validator 的请求 还能复用旧字节。判据是两份只差 parent 的 map 产生相同的材料——读代码就能确认,那条拼串里根本没有 这个字段。parent 已进材料。 守卫 29 → 31。两条新断言把 title 与 kind 都按住不动,所以不可能靠另外两个字段蒙过去;另有一条 空串与 null 必须一致(两者在页面上渲染成同一行)。变异 13 → 14。 连带:改这一处打断了三个已有变异的锚点——title/kind 那几行原本以 `));` 结尾,加一行之后 kind 那行不再是串的结尾。按规矩只改锚点,并把解释性注释挪到 `parts.push(` 的上面,让三行字段保持连续: 锚点不该隔着注释,改个措辞就失效。 **二、两条「比较」其实是自己跟自己比。** 合成会话里 compactions 与 compactionTurns 是零。零意味着把页面上 `stats.compactions += 1` 与 `stats.compactionTurns += 1` 删掉,逐字段比较仍然 0 === 0 全绿——那两条不是比较,是这个文件 自己安排好的一对零。断言数量看不出这一点,只有变异能。 生成器每 23 轮造一个只装一条 checkpoint 的轮次,那是服务端唯一会归成 compaction 的形状。两个 计数器现在都非零,删掉那两行会被抓到并按名报 whole-session compactions / whole-session compactionTurns(已实测)。 同时删掉一条循环断言。原来那句 `compactionTurns === 0` 断言的是这个文件自己选的数,而它保证的 正是它随后要去比较的前提。真正的不变式——真实会话不该有裸 checkpoint 轮——属于另一个守卫的 既有一条断言。**在自己的 fixture 上断言自己造出来的数,证明不了任何事。** 变异 8 → 10。套件 90/90。
…r count The whole-session section compares twenty numbers between the page's re-derivation and the server's own. Both sides are correct in a passing run, so a comparison can only fail if one of them is broken — which means a field with no mutation is a field whose comparison could be satisfied by a page that never counts it at all. Three fields had a mutation; seventeen did not. The fixture is now a generation view over a lineage rather than one whole-session pass, and that is not tidiness. `messagesAll` counts the conversation and `messages` counts what the view holds; while the view was the whole conversation the two were one number, so re-scoping 「全程 N」 onto the range changed nothing any assertion could see. One generation stays outside the payload, and the guard asserts the two figures differ before asserting anything about either. Every compared field is now checked non-zero by the guard itself, driven off the list the comparisons are driven off, so the next field added there arrives covered rather than waiting to be discovered at zero. Two mutations break this guard's own generator instead of the page's code: removing the checkpoint turns, and making the view the whole conversation again. Without them the twenty preconditions are a claim about a session nobody has tried to starve. `mutate-time-range.py` 10 -> 29, all caught by name. verify-time-range.mjs 153 -> 172 assertions; suite 90/90.
Two of the time-range control's cases edit the guard's own session generator, which made `tests/verify-time-range.mjs` a file a control rewrites in place — and it was not in the list the suite hashes around its mutation controls. A hard kill during that window would have left a generator with no checkpoint turns in it, and every later run would have reported that as ordinary red rather than as residue. The membership rule is "files a control rewrites", not "files that look important", and this one had stopped being protected the moment it became a target. Checked by planting one byte of residue in it: the startup warning names the file.
|
这六个提交围绕同一条判据:一条比较只有在两侧之一被破坏时才会红。所以一个没有被变异的字段,就是一条可以由「根本不数它的页面」满足的比较。绿色的数量从来不是证据,能被删掉的那一行才是。
|
|
更正:上一条评论里关于 我写过:「成员规则是『被改写的文件』,不是『看起来重要的文件』」。那句话不成立。 那份清单当时确实漏了两个文件 —— 为什么漏了没有人报:清单是手工维护的,45 个控制要与它保持一致,而没有任何断言拥有「成员规则」这条,所以漂移完全静默。这与本评论开头那条判据是同一个形状 —— 一个没人能证伪的清单,就是一个迟早会漏的清单。 改法不是补两个条目,是取消清单。 这条新规则自带能失败的证明:
|
| 之前 | 现在 | |
|---|---|---|
verify-sessions-etag.mjs 断言 |
31 | 32(去掉 1 条承诺假性质的,加 2 条真的) |
mutate-sessions-etag.py 变异 |
14 | 15 |
verify-review16.mjs 断言 |
41 | 42 |
mutate-review16.py 变异 |
37 | 38 |
| 残渣比对的对象 | 9 个文件的手工清单 | 整个包目录 |
在 e2c514c 上跑 node tests/run.mjs:90 项守卫(43 个 verify-*.mjs + 45 个 mutate-*.py + 2 项检查)全绿。
把记录索引退役、新鲜度层落下来、总览条按像素列聚合,并从段格式里去掉两个只写不读的字段
读点在哪
tests/随包分发:.minimax-plugin/plugin.json没有files/exclude,整个目录会装到每个读者的机器上。所以下面每个守卫名都是这个包的一部分。
一、退役记录索引(
1ef5f71)磁盘上的索引目录是死的产物,而每次发布都要重算一整份记录投影来喂它。
miniapp/node/trajectory-index.mjs整个删除search-segments.mjs并收窄到只产段行真正需要的字段;段行从此只有一个来源-1,且不得误归文件 0」这条语义原样保留二、新鲜度层(
4096b5e)横幅写着「正在重试」,而它能被收掉的唯一条件是「服务端回了一个带 body」——304 不算。
cacheKey只由dir|sizeBytes|lastActiveAt决定,会话一安静,之后每次轮询都是 304。于是一次瞬时抖动 + 一个安静的会话 = 横幅永久停留,并且一直宣称自己在重试。
freshness模型,唯一写者noteFresh()三、总览条按像素列聚合(
55e96f2)改前最坏会话(36,525 条)下这条带子建 36,525 个
.ov-span,每个至少 2px 宽 —— 约 36,000 个互相压在一起,872px 的带子上每格读者看到的是「最后画上去的那条」。
同一份探针、同一台机器、同一个桩:
renderOverview改前节点数在 20,000 条就饱和(872px × 3 车道 = 2,616),与记录数无关。outerHTML 从约 2.7 MB 降到约 190 KB。
收益只算一遍:改后 192.5 ms 里
overviewModel()自己占 78.6 ms(41%),其余约 114 ms 是那 36,525 次循环本身。「每记录一个节点」的成本归零了,「每记录走一遍」的成本原样还在。
颜色、透明度、车道、
data-kind全部仍来自同一条 CSS 规则——这次只决定有多少个节点和它站在哪,调色板没有第二个来源。这也是没有 canvas 的原因。
条带没被排版过时退回「每记录一列」,也就是改动前的行为——这不是防御性写法,而是这个函数不该把画面
变得更糟的保证。
四、段格式退役
byteStart/byteEnd(155227c)这两个字段写进每一行段记录的 JSON 前缀,shipped 搜索路径里没有任何一处把它们读回来:
readSegmentHits只取 5 个字段后丢掉其余,而真正需要定位符的/api/trajectory/record与/raw读的是内存 lineage 缓存,不碰段文件。
实测代价:4.36% 的段面,每行 49 字节(1,177 行、1.3 MB 的样本;49 × 1,177 = 57,673 字节 ÷ 4.36% ≈ 1.32 MB)。
SEGMENT_SCHEMA_VERSION2 → 3。已落盘的段全部失效一次,每个会话首次搜索重建一次locatorRowsFromPayload→fileIdxRowsFromPayload,只返回fileIdx。函数连名一起改——它不再产出locator,名字声称的东西变了而名字不变
fileIdx留着:它是唯一的分代分组键两条「它没了」的断言都写成能被真的打红的形状:输入里带着这两个字段,输出里没有。只写「输出里没有」
的话,退役之前它也会绿。退役有两扇门(投影、
buildSegment),各配一条变异。五、悬停和点击各自要的东西不一样(
90d3fb3)聚合之后只有占到过某一列的记录在 DOM 里有节点,而命中测试当时读 DOM 上的
data-record-id——输掉所有列的记录既点不到也悬停不到。上一批为它们写的悬停标记因此永远不会亮。
这条不变量原本由一条读源码文本的断言守着,两次外部评审各找出一种它挡不住的写法。最后不再按语法
枚举返回值,改为在沙箱里跑
spanAt并断言返回的就是模型里那一个对象——mutate-overview.py里的假壳变异证明这条断言确实会红。
守卫与变异
整套件 88/88,无 shipped 文件还原漂移。
verify-freshness.mjsmutate-freshness.pyverify-overview.mjsmutate-overview.pyverify-search-segments.mjsmutate-search-segments.pyverify-file-index-rows.mjsmutate-file-index-rows.pyverify-search-endpoint.mjsmutate-generation-counts.py每条新不变量都配一条变异,且变异必须按名被抓住——崩溃不命名规则,这个项目把它算成比红断言更弱的结果,
变异控制会报
UNNAMED。未做
overviewModel()的结果不缓存:条带与账本共用同一份模型,缓存它要先答「模型何时失效」Need help on this PR? Tag
@codesmith-botwith what you need.