Repository navigation
Commit da780da
authored
mmc-trajectory: 退役记录索引、落下来鲜度层、总览条按像素列聚合,并从段格式里去掉两个只写不读的字段 (#21)
* Give the session list a validator, and stop one clock driving two fetches
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.
* Make every nameable session openable, not just the 30 in the picker
`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.
* Stop one growing session from pinning four copies of its own payload
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.
* Let the parsed-row assertions measure what they were measuring
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.
* Stop holding a session's parsed rows open just to answer 原文
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.
* Merge stored copies into one message, and decide a turn's opener over 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.
* Pin the identity rules with assertions and mutations
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.
* Follow a session's active generation, and key rows and time spans by 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.
* Pin the generation fallback and the record-level keys with assertions 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.
* Describe the ledger twice: once as an index on disk, once as searchable 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.
* Search the whole lineage instead of the loaded window
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.
* Stop sending the record bodies; send four flags and ask when one is opened
`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.
* Say which numbering a tail was cut from, or renumbering lands it in the 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.
* Give the time-range guard a window it can still prove something with
`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.
* Answer a search from what is already on disk, and stop losing matches 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.
* Put a search hit's answer on the segment's own line, and take the record 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.
* Put back the one counter the range-scoped KPI had lost
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。
* 删掉十一个没有消费者的 class,并让这件事变成一条守卫
样式表里有 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 里抄第二份,
意味着以…1 parent 2330faa commit da780da
65 files changed
Lines changed: 13919 additions & 800 deletions
File tree
- plugins/avatasia/mmc-trajectory
- .minimax-plugin
- miniapp
- client
- node
- tests
- fixtures
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
5 | | - | |
| 5 | + | |
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
7 | | - | |
| 7 | + | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| |||
131 | 131 | | |
132 | 132 | | |
133 | 133 | | |
| 134 | + | |
134 | 135 | | |
135 | 136 | | |
136 | 137 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
7 | | - | |
| 7 | + | |
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| |||
117 | 117 | | |
118 | 118 | | |
119 | 119 | | |
| 120 | + | |
120 | 121 | | |
121 | 122 | | |
122 | 123 | | |
| |||
0 commit comments