diff --git a/CHANGELOG.md b/CHANGELOG.md index 9474660..f7b0df1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## Unreleased -- Add a bounded fixed-interval/termination `ResourceSampleV1` ledger for shared search with deterministic logical retention, category-specific interval yield, early-versus-post-first summaries, fail-closed exact-resume persistence, strict compact machine projection, privacy-safe live heap/RSS observations, and explicit monotonic run-wide positions across additive goal passes. This partial #216 slice changes no search policy, makes no complete owner-attribution claim, and leaves checkpoint/discovery/pressure-triggered sampling for later work. +- Extend shared-search observability to nested schema v2 with monotonic sample sequences, canonical coalesced reason vectors, an exact 15-bit trigger mask, a fixed eight-count boundary-local trigger-yield tuple, category/frontier/cadence/termination triggers, and category-specific first/last discovery, current/longest dry-state, and identities-per-million-transition facts. The redundant vector/mask/tuple preserves exact retained-boundary triggers across compaction while aggregate interval deltas are rebuilt: adjacent complete-history samples require the tuple to equal the semantic interval delta, while genuine compaction gaps or incomplete v1 history allow only a componentwise-bounded tuple. Observability-v1 migration synthesizes masks only from explicit historical boundaries and uses a zero tuple to mean no v1 trigger was recorded, not that an interval was event-free. Sequence validation applies monotonic and feasible record/state/cadence bounds without claiming exact replay of discarded compaction history. Emitted v2 checkpoints add a domain-separated, key-order-independent canonical SHA-256 self-check binding every fixed known ledger field; it detects stale or accidental mutation but is not authentication because a writer can recompute it, and persisted payload/manifest digests remain the external artifact-corruption boundary. V1 and pre-ledger inputs gain the nested checksum on their first emitted v2 checkpoint. Live process sampling and progress output remain bounded to cadence and final termination while the compacted deterministic ledger retains event boundaries. Checkpoint construction remains side-effect-free and deterministic: the persisted ledger participates in stable checkpoint identity, while observability-v1 checkpoints migrate explicitly with incomplete event history and nullable facts rather than invented history. This remains an independent partial #216 slice: `checkpoint`, `epoch`, and generic `pressure` reasons are reserved but not emitted; retained-owner accounting remains incomplete; no resource, allocation, or stopping policy is activated; #216 remains open; and the earlier V1 overhead study is not an exact-head overhead claim for this V2 implementation. +- Add the initial bounded fixed-interval/termination `ResourceSampleV1` ledger for shared search with deterministic logical retention, category-specific interval yield, early-versus-post-first summaries, fail-closed exact-resume persistence, strict compact machine projection, privacy-safe live heap/RSS observations, and explicit monotonic run-wide positions across additive goal passes. The nested-v2 extension above adds category and frontier event boundaries; checkpoint, epoch, and generic pressure boundaries remain later work. - Bound schema-v1 checkpoint readback by stored and decompressed bytes, classify reopen failures as corrupt, unsupported, or resource-limited, and add canonically self-bound private manifests so listing and retention use bounded metadata I/O without opening new frontiers. Full payload digests remain an open/resume boundary; no-clobber same-ID publication, payload-first crash recovery, and pair-inclusive quotas preserve existing v1 JSON/gzip reads, stable IDs, and exact resume pending framed schema v2. - Add an opt-in bounded NDJSON evidence stream for marathon-scale external consumers. It emits replayable numeric ending/runtime witnesses with global elapsed timestamps as they are retained, then a compact terminal summary without constructing the monolithic full-report JSON string. - Treat `--max-time` as a total CLI deadline and retain bounded time/heap headroom for clean report finalization. Explicit heap caps now expose the lower search watermark separately from the full process envelope. diff --git a/docs/progress-ndjson.md b/docs/progress-ndjson.md index 6a528e5..ca6e992 100644 --- a/docs/progress-ndjson.md +++ b/docs/progress-ndjson.md @@ -41,7 +41,7 @@ Optional fields: | `unvisitedKnots` | number | Knots not yet reached by any pass in this run. Non-increasing within a run. | | `knotsVisited` | number | Cumulative authored knots reached. Present on `discovery` events. | | `discoveries` | object | Numeric deltas first observed at this event: `endings`, `runtimeErrors`, `knotsVisited`, `visibleOutcomes`, `assertionViolations`, `goalsReached`, and `stagesReached`. Present only on `discovery` events. | -| `sharedObservability` | object | Shared-search `resource` events only: one deterministic logical retention/yield sample paired with observed process heap/RSS. `runWideState` is the explicit outer-run position; nested `sample.state` remains local to its shared pass. See [shared-search observability](shared-search-observability.md). | +| `sharedObservability` | object | Shared-search `resource` events only: one deterministic `ResourceSampleV2` paired with observed process heap/RSS. `runWideState` is the explicit outer-run position; nested `sample.state` remains local to its shared pass. See [shared-search observability](shared-search-observability.md). | | `status` | string | Terminal process status: `complete`, `cancelled`, or `error`. Hosted wrappers also use queue/job states. | | `stopReason` | string | Binding terminal reason such as `exhaustive`, `state_budget`, `depth_limit`, `time_limit`, `memory_limit`, `frontier_limit`, `worker_failure`, `compile_error`, `cancelled`, or `error`. | | `outcome` | string | Result classification separate from the stop cause: `clean`, `issues_found`, `review_required`, or `compile_error`. | @@ -50,6 +50,8 @@ Progress counts are cumulative over the run, not per-pass, so a consumer can ren Fields may be added in a future schema or minor version. Consumers should branch on `type`, use fields they understand, and ignore unknown fields. +Nested contracts carry their own versions. A `resource` event still has progress `schemaVersion: 1`, while its `sharedObservability` live observation and `sample` use schema v2. Consumers must branch on each nested discriminator rather than assuming it matches the outer progress version; historical v1 observations remain valid recorded data. + ## Lifecycle A normal complete run looks like this: @@ -88,7 +90,7 @@ Privacy-safe discovery event: Shared-search resource event (abridged here; consumers should ignore unknown nested fields): ```json -{"schemaVersion":1,"sequence":6,"type":"resource","elapsedMs":702,"statesExplored":10000,"stateBudget":100000,"budgetFraction":0.1,"pass":"shared:deep-novelty-v1:seed=1","sharedObservability":{"schemaVersion":1,"pass":"shared:deep-novelty-v1:seed=1","runWideState":10000,"sample":{"schemaVersion":1,"boundary":"interval","state":10000,"retention":{"schemaVersion":1,"current":{"totalAccountedBytes":8388608}},"yield":{"schemaVersion":1,"fromStateExclusive":0,"throughState":10000,"delta":{"critical":{"runtimeErrors":0,"assertionViolations":0}}}},"process":{"schemaVersion":1,"scope":"process","heapUsedBytes":67108864,"heapTotalBytes":83886080,"rssBytes":104857600,"externalBytes":2097152,"arrayBuffersBytes":1048576,"comparedLogicalAccountedBytes":8388608,"unattributedBytes":58720256}}} +{"schemaVersion":1,"sequence":6,"type":"resource","elapsedMs":702,"statesExplored":10000,"stateBudget":100000,"budgetFraction":0.1,"pass":"shared:deep-novelty-v1:seed=1","sharedObservability":{"schemaVersion":2,"pass":"shared:deep-novelty-v1:seed=1","runWideState":10000,"sample":{"schemaVersion":2,"sequence":17,"reasons":["cadence","authored_knot"],"triggerMask":33,"triggerYield":[0,0,0,0,1,0,0,0],"state":10000,"retention":{"schemaVersion":1,"current":{"totalAccountedBytes":8388608}},"yield":{"schemaVersion":1,"fromStateExclusive":9999,"throughState":10000,"delta":{"critical":{"runtimeErrors":0,"assertionViolations":0},"authoredCoverage":{"knotsVisited":1}}}},"process":{"schemaVersion":1,"scope":"process","heapUsedBytes":67108864,"heapTotalBytes":83886080,"rssBytes":104857600,"externalBytes":2097152,"arrayBuffersBytes":1048576,"comparedLogicalAccountedBytes":8388608,"unattributedBytes":58720256}}} ``` Terminal event: @@ -120,7 +122,19 @@ for await (const line of stderrLines) { `discovery` means that a cumulative run counter increased. It is useful for a concise terminal update, hosted status, or agent scheduling, but it is not a finding record and does not replace the final report. Counts stay privacy-safe by omitting identities, story labels, source locations, messages, paths, and variable data. A later bounded run can still find more. -`resource` is emitted only by shared search at fixed transition boundaries and termination. Checkpoint operations, discovery events, and memory/frontier pressure do not add sampling boundaries in this partial #216 slice. `sample` contains deterministic aggregate counts and logical byte estimates. `process` contains nondeterministic Node process observations and must not participate in report identity, exact-resume comparison, frontier order, or a coverage claim. The outer `statesExplored` is CLI-process progress and equals the run base plus `sharedObservability.runWideState`. The nested `sample.state` always belongs to that shared pass. During additive goal work, Inkcheck advances `runWideState` by the general pass's consumed work instead of rewriting the directed pass's local sample position; consumers must not infer this offset from pass names. +The bounded shared-pass ledger records the fixed cadence; new runtime errors, assertion violations, goals, stages, authored knots, visible outcomes, bounded semantic transitions, or exact terminal variants; frontier compaction or the existing shared-frontier ceiling; and termination. If several triggers occur at one pass-local state, one v2 sample coalesces them into a deduplicated `reasons` vector in canonical schema order. Consumers should use the vector rather than infer a primary trigger from array position. + +The complete reason vocabulary, in order, is `cadence`, `runtime_error`, `assertion_violation`, `goal_reached`, `stage_reached`, `authored_knot`, `visible_outcome`, `semantic_transition`, `terminal_variant`, `frontier_compaction`, `frontier_ceiling`, `checkpoint`, `epoch`, `pressure`, and `termination`. `checkpoint`, `epoch`, and generic `pressure` are reserved but are not emitted by this slice. Checkpoint save/reopen and memory/time pressure therefore remain absent as resource-event boundaries; no allocation or stopping policy reads these events. + +`triggerMask` is the exact bounded numeric encoding of the same vector: bits 0 through 14 correspond to that canonical order, and a valid sample has a set bit exactly for each listed reason. The example's cadence and authored-knot reasons therefore produce mask `33`. This mask stays attached to the exact ending boundary when deterministic sample compaction rebuilds `yield.delta` across a wider retained interval; consumers must not infer boundary triggers from that aggregate delta. + +`triggerYield` supplies the exact boundary-local counts for reason bits 1 through 8: runtime errors, assertion violations, goals, stages, authored knots, visible outcomes, semantic transitions, and terminal variants. Its eight safe nonnegative integers have nonzero positions exactly matching those yield reasons. Between adjacent sequences with complete event history, this tuple equals the semantic-category portion of `yield.delta` exactly. Across a genuine compaction gap or incomplete v1 history, compaction/migration preserves the boundary tuple while the aggregate delta covers a wider or incompletely known interval, so each tuple component is only required to be no greater than its matching delta count. A v1-migrated all-zero tuple means v1 recorded no exact category trigger at that boundary; it does not prove the wider interval contained no category event. + +The nested checkpoint-only `integritySha256` self-check is intentionally absent from live progress. A `resource` event carries the validated sample projection and observational process values, not the persisted `SharedObservabilityCheckpointV2` envelope or its artifact-integrity metadata. + +Live `resource` events are intentionally sparser than that deterministic ledger: the CLI samples process memory and emits them only at the fixed cadence and final termination. A cadence observation is delayed until its pass-local state is closed, so a later same-state reason is coalesced before append-only output and no partial revision is emitted. With the production cadence and 100-million-state ceiling, one uninterrupted shared-pass execution emits at most 10,001 resource events; category-heavy stories do not increase the count. + +`sample` contains deterministic aggregate counts, canonical reason codes, their equivalent bounded numeric mask, the fixed boundary-local trigger tuple, transition positions, and logical byte estimates. The full pass telemetry's separate `timing` summary contains category dry-gap/rate facts; it is not duplicated into every live sample. `process` contains nondeterministic Node process observations and must not participate in report identity, checkpoint identity, exact-resume comparison, frontier order, or a coverage claim. The outer `statesExplored` is CLI-process progress and equals the run base plus `sharedObservability.runWideState`. The nested `sample.state` always belongs to that shared pass. During additive goal work, Inkcheck advances `runWideState` by the general pass's consumed work instead of rewriting the directed pass's local sample position; consumers must not infer this offset from pass names. ## Privacy diff --git a/docs/report-schema-v1.md b/docs/report-schema-v1.md index 671d75b..32638f1 100644 --- a/docs/report-schema-v1.md +++ b/docs/report-schema-v1.md @@ -57,7 +57,11 @@ Portfolio reports record the resolved worker ceiling and `concurrencyMode` (`aut Shared-search pass telemetry includes `sharedMemory.current`, per-component `peak` values, configured pending-state/byte `limits`, `releasedNodes`, and `frontierCompactions`. Components cover pending and active state JSON/variable snapshots, retained witness ancestry, dedupe keys, semantic indexes, frontier references, and findings. Serialized strings use UTF-8 byte counts; structural bytes are documented estimates. `totalAccountedBytes` is deterministic retained-payload accounting, not process heap or RSS. -Shared passes additionally expose `sharedObservability` schema v1: a deterministic ledger sampled every 10,000 transitions and at termination, bounded to 128 retained samples. Checkpoint, discovery, and pressure events are not sampling boundaries in this partial #216 slice. Each sample keeps current/per-field-peak logical retention and separate cumulative/interval yield categories for critical findings, intent, authored coverage, visible outcomes, bounded semantic transitions, exact terminal variants, and raw territory. `yieldSummary` keeps through-first-useful and post-first-useful category vectors separate and deliberately has no scalar score. Compact machine responses reconstruct a strict numeric whitelist containing only the latest sample plus the summary. Observed process heap/RSS appears only on live progress and bounded evidence-stream termination, never in this canonical report. See [shared-search observability](shared-search-observability.md) for the compatibility, privacy, and incomplete-owner-accounting boundaries. +Shared passes additionally expose nested `sharedObservability` schema v2 while the surrounding report remains schema v1. The deterministic ledger is bounded to 128 retained `ResourceSampleV2` entries. A sample contains a monotonic logical sequence, pass-local state, current/per-field-peak logical retention, cumulative/interval yield, one deduplicated canonical `reasons` vector, its exact 15-bit `triggerMask`, and an eight-count `triggerYield` tuple aligned to yield-reason bits 1 through 8. The vector and mask must agree exactly; tuple values are safe nonnegative integers; and nonzero tuple positions equal the vector's yield-reason subset. For adjacent sequences with complete event history, the tuple equals the semantic-category portion of the interval delta exactly. Across a genuine compaction gap or incomplete v1 history, it remains boundary-local while the aggregate delta is wider, so each component must only be no greater than the corresponding delta count. Triggers in this slice are the 10,000-transition cadence; category events for critical findings, intent, authored coverage, visible outcomes, bounded semantic transitions, and exact terminal variants; frontier compaction or the existing frontier ceiling; and termination. Coincident triggers produce one coalesced sample. Compaction preserves that exact boundary vector/mask/tuple while rebuilding the aggregate interval `yield.delta`; counts inside the wider delta do not become ending-boundary triggers. A v1-migrated zero tuple records that v1 supplied no exact category trigger, not that its wider interval was event-free. The stable vocabulary also reserves `checkpoint`, `epoch`, and `pressure`, but those boundaries are not emitted yet. + +`yieldSummary` keeps through-first-useful and post-first-useful category vectors separate and deliberately has no scalar score. `timing` separately reports identity count, first/last transition positions, longest/current dry-state distances, and identities per million transitions for `critical`, `intent`, `authoredCoverage`, `visibleOutcomes`, `semanticTransitions`, and `terminalVariants`. These are category-specific transition facts, not elapsed-time rates, plateau estimates, or coverage proof. A report resumed from an observability-v1 checkpoint carries `eventHistoryComplete: false`. At migration, its identity counts remain calculable from cumulative yield, its per-million rates are calculable once completed transitions are nonzero, and unprovable position/dry-gap fields are `null`. Later v2 events populate only facts that become knowable after reopening; missing-prefix facts remain nullable. + +Compact machine responses reconstruct and validate a strict bounded whitelist containing the latest sample—including its exact reason vector, trigger mask, and trigger-yield tuple—`yieldSummary`, `timing`, and v2 cadence/completeness counters rather than copying arbitrary nested telemetry. The checkpoint-only `SharedObservabilityCheckpointV2.integritySha256` self-check is not report telemetry and is not projected here. Observed process heap/RSS appears only on live progress and bounded evidence-stream termination, never in this canonical report. Owner accounting remains incomplete, and no event in this slice changes allocation, retention, or stopping. See [shared-search observability](shared-search-observability.md) for canonical reason order, checkpoint compatibility, privacy, and the still-open #216 boundaries. Portfolio pass telemetry additionally contains `portfolioMarginalCurve` and `portfolioMarginalSummary`. The pass-local curve answers “what did this explorer find itself?”; the marginal curve answers “which findings did this explorer add first to the combined portfolio?” Runtime and assertion credit uses stable identities, approximate runtime locations are conservatively normalized for allocation credit, and every exact ending, visible outcome, authored knot, goal/stage, or critical finding is paid once. Cross-pass state novelty remains zero because independent pass hashes are not comparable. Shadow allocation reads this marginal curve when present; diagnostics retain both. @@ -75,6 +79,6 @@ Choice indices are authoritative for replay because authored labels need not be ## Compatibility -Within schema v1, fields may be added but existing field meanings and types do not change. A breaking machine-contract change increments `schemaVersion`. Human text/Markdown formatting is not part of the JSON compatibility contract. +Within schema v1, fields may be added but existing field meanings and types do not change. Explicitly versioned nested contracts may add a new discriminated version without changing the report envelope version: `passes[].sharedObservability` readers should branch on its own `schemaVersion` and accept historical v1 as well as current v2. A breaking report-envelope change increments the top-level `schemaVersion`. Human text/Markdown formatting is not part of the JSON compatibility contract. Approximate source locations retain `approximate: true`. A bounded non-finding remains partial unless `explore.exhaustive` is true. diff --git a/docs/shared-checkpoint-schema-v1.md b/docs/shared-checkpoint-schema-v1.md index fd74fc1..3bd14ff 100644 --- a/docs/shared-checkpoint-schema-v1.md +++ b/docs/shared-checkpoint-schema-v1.md @@ -20,9 +20,15 @@ const continued = exploreSharedResumable(storyJson, knots, externals, { ## Exact-resume contract -Schema v1 stores the partially expanded choice cursor, pending nodes and witness ancestry, deep/novelty/seeded frontier internals, PRNG state, deduplication and semantic indexes, findings, coverage, discovery-curve state, counters, deterministic memory accounting, and the additive deterministic resource/yield ledger. Tests pause partway through a choice list, round-trip the checkpoint through JSON, and require the resumed result and next checkpoint to deep-equal uninterrupted execution at the same final grant. +Schema v1 stores the partially expanded choice cursor, pending nodes and witness ancestry, deep/novelty/seeded frontier internals, PRNG state, deduplication and semantic indexes, findings, coverage, discovery-curve state, counters, deterministic memory accounting, and the additive deterministic resource/yield ledger. New checkpoints nest observability schema v2 inside this unchanged top-level checkpoint schema. Tests pause partway through a choice list, round-trip the checkpoint through JSON, and require the resumed result and next checkpoint to deep-equal uninterrupted execution at the same final grant. -The resource/yield fields are additive within schema v1. Older v1 checkpoints that lack them remain readable and resume the exact search frontier; their new telemetry reports `historyComplete: false` because Inkcheck does not reconstruct missing interval history. A checkpoint that contains the ledger must resume with the same sampling interval. Saving or reopening a checkpoint does not itself create a resource sample; this partial #216 slice samples only fixed transition intervals and termination. Live process heap/RSS is observational and is never written to checkpoint JSON or included in its stable ID. See [shared-search observability](shared-search-observability.md). +Observability v2 persists monotonic sample sequences, canonical coalesced reason vectors, their exact 15-bit `triggerMask` values, fixed eight-count `triggerYield` tuples, category timing/dry-gap state, and separate cadence/event-history completeness. Bits 0 through 14 map to the exported canonical reason order, while tuple positions map to yield-reason bits 1 through 8. Validation requires the mask and vector to agree exactly; each tuple count to be a nonnegative safe integer; and its nonzero positions to equal the vector's yield-reason subset. For adjacent sequences with complete event history, the tuple must equal the semantic-category portion of the interval delta exactly. Across a genuine compaction gap or incomplete v1 history, it is boundary-local while the interval has widened, so each component instead must be no greater than the matching aggregate delta. The vector, mask, and tuple remain attached to their exact boundary when compaction rebuilds an aggregate `yield.delta` across several removed samples. Sequence validation uses monotonicity plus total-record, state, cadence, and possible-remaining-record bounds; it requires contiguous values only before compaction, and does not claim exact replay of discarded compaction choices. Calling `checkpoint()` is side-effect-free: it does not record a checkpoint reason, add a sample, advance a sequence, or change timing. Checkpoint save/reopen also emits no boundary in this slice, and the termination sample is added only to finalized result telemetry after the resumable checkpoint has been captured. The persisted deterministic ledger participates in canonical checkpoint content and therefore in the stable checkpoint ID; live process heap/RSS does not. + +Each emitted `SharedObservabilityCheckpointV2` includes a 64-character lowercase `integritySha256`. Inkcheck accepts exactly the known v2 ledger shape, rebuilds every fixed ledger field—including all nested sample, retention, yield, counter, base/cursor, milestone, completeness, and timing fields—in schema-defined order, excludes the checksum from that body, prefixes `inkcheck:shared-observability-checkpoint:v2\0`, and SHA-256 hashes the canonical JSON bytes. Raw object-key order is irrelevant, and accepted input is re-emitted in canonical key order. Missing or unknown fields, a malformed checksum, or a stale digest after accidental mutation fail closed. + +`integritySha256` is a deterministic self-check, not authentication: a writer capable of changing the checkpoint can recompute it. It catches stale or accidental internal ledger mutation and binds all fixed known fields together. The persisted artifact's manifest digest and full stored-payload digest remain the separate external corruption/readback boundary; after decoding that payload, the nested digest checks the logical observability ledger. + +Older top-level schema-v1 checkpoints remain readable. An artifact is first validated against its original logical bytes and stable ID; migration does not rewrite that stored artifact or change its ID. A checkpoint with the earlier observability schema v1 resumes through a deterministic v2 migration. It synthesizes each retained mask from the explicit v1 interval/termination boundary, not from the sample's potentially rebuilt aggregate yield delta, and sets `triggerYield` to eight zeros because v1 recorded no exact category trigger tuple. Zero means no v1 trigger was recorded, not that no category event occurred in the interval. Its fixed-cadence prefix remains available, but missing category/frontier event history is marked with `eventHistoryComplete: false`. At migration, identity counts remain calculable, the per-million rate is calculable once state is nonzero, and the unprovable first/last position and dry-gap fields are `null`. Subsequent v2 events populate only facts that become knowable after reopening; Inkcheck never invents the missing prefix. A checkpoint that predates the observability ledger continues with `historyComplete: false`. Neither older form contains the nested digest; its first emitted v2 checkpoint gains the canonical `integritySha256` and one deterministic identity without rewriting the input artifact. A checkpoint that contains either ledger version must resume with the same sampling interval. See [shared-search observability](shared-search-observability.md). The checkpoint is bound to: @@ -46,10 +52,12 @@ See [local resumable checkpoints](local-checkpoints.md) for freshness, privacy, The logical checkpoint remains schema v1 JSON. New local artifacts stream that JSON through gzip as `.json.gz`; stable IDs still hash the same uncompressed logical checkpoint, and readers continue to accept earlier plain `.json` artifacts. Storage compression therefore changes neither deterministic frontier order nor split-run equivalence. -New artifacts have a small canonical self-checksummed metadata sidecar, allowing list and retention operations to read bounded metadata plus payload file size without opening the frontier. That checksum detects isolated field corruption but is not authentication against a hostile local writer who can recompute it. Opening and resuming remain full-integrity boundaries: manifest validation and the full stored-payload digest precede bounded decompression, then the original stable-ID, envelope, configuration, and freshness checks run. Read failures explicitly distinguish corruption, unsupported schemas, and an artifact that cannot fit the schema-v1 readback envelope. +New artifacts have a small canonical self-checksummed metadata sidecar, allowing list and retention operations to read bounded metadata plus payload file size without opening the frontier. That checksum detects isolated field corruption but is not authentication against a hostile local writer who can recompute it. Opening and resuming remain full-integrity boundaries: manifest validation and the full stored-payload digest are the external stored-file corruption boundary and precede bounded decompression; the nested observability `integritySha256` is then checked as part of logical checkpoint validation, followed by the original stable-ID, envelope, configuration, and freshness checks. Read failures explicitly distinguish corruption, unsupported schemas, and an artifact that cannot fit the schema-v1 readback envelope. ## Deliberate limits -Schema v1 supports only base `shared:deep-novelty-v1`; assertions, goals, variable-aware steering, goal-aware steering, and the default portfolio are rejected rather than resumed approximately. Hosted/MCP resume, frontier partitioning, and cross-version migration remain future work. Because v1 is one JSON value, it also cannot safely reopen a payload above the runtime's maximum string length even when gzip keeps the stored file below quota. Its compressed buffer, decompressed buffer, JSON string, and parsed graph may overlap in memory. The reader reports an unsafe boundary as a resource limit and preserves the artifact; framed incremental schema v2 is required to remove both the string ceiling and this peak-memory shape. +Schema v1 supports only base `shared:deep-novelty-v1`; assertions, goals, variable-aware steering, goal-aware steering, and the default portfolio are rejected rather than resumed approximately. Hosted/MCP resume and frontier partitioning remain future work. The nested observability-v1-to-v2 migration does not change this storage boundary: the checkpoint is still one JSON value and cannot safely reopen a payload above the runtime's maximum string length even when gzip keeps the stored file below quota. Its compressed buffer, decompressed buffer, JSON string, and parsed graph may overlap in memory. The reader reports an unsafe boundary as a resource limit and preserves the artifact; framed incremental artifact schema v2 is required to remove both the string ceiling and this peak-memory shape. + +The observability reason vocabulary reserves `checkpoint`, `epoch`, and `pressure`, but this slice emits none of those boundaries. It also adds no resource policy, owner ceiling, epoch behavior, checkpoint-v2 encoding, or claim that issue #216 is complete. Checkpoint JSON can contain authored choice text, ending text, variable snapshots, serialized Ink runtime state, and exact witness paths. Treat it as sensitive project data and do not commit checkpoints by default. diff --git a/docs/shared-search-observability.md b/docs/shared-search-observability.md index 1ee6da5..bb2f43c 100644 --- a/docs/shared-search-observability.md +++ b/docs/shared-search-observability.md @@ -1,16 +1,55 @@ -# Shared-search observability v1 +# Shared-search observability v2 -Inkcheck's base shared search now records a bounded, versioned ledger of deterministic logical retention and category-specific yield. Live CLI progress can pair the same sample with observed Node process memory. This is a partial implementation slice tracked by [issue #216](https://github.com/chaoz23/inkcheck/issues/216), which remains open; it is not the complete long-run resource policy. +Inkcheck's base shared search records a bounded, versioned ledger of deterministic logical retention and category-specific yield. Live CLI progress can pair the same deterministic sample with observed Node process memory. This remains a partial implementation of [issue #216](https://github.com/chaoz23/inkcheck/issues/216): it adds event-aware evidence, not a resource, allocation, or stopping policy. Issue #216 remains open. ## Deterministic ledger -Each shared pass exposes `passes[].sharedObservability` with `schemaVersion: 1`. Inkcheck records a `ResourceSampleV1` every 10,000 completed transitions and at termination. Tests and embedders may request a different positive interval through the library option `sharedObservabilityIntervalStates`; CLI users receive the fixed default. +Each shared pass exposes `passes[].sharedObservability` with `schemaVersion: 2`. Its `ResourceSampleV2` entries contain: -Those are the only sampling boundaries in this slice. Checkpoint save/resume, discovery events, memory or frontier pressure, and other lifecycle events do not trigger an additional sample. Boundary-specific sampling and the policy for choosing it remain deferred under #216. +- `schemaVersion: 2`; +- a monotonic logical `sequence` retained across deterministic sample compaction; +- one canonical, sorted `reasons` vector; +- its exact 15-bit numeric `triggerMask`; +- an exact eight-count `triggerYield` tuple; +- the pass-local transition `state`; +- deterministic logical `retention`; and +- cumulative and interval `yield` vectors. -The ledger retains at most 128 samples. If it grows beyond that bound, Inkcheck deterministically keeps the first and latest boundaries and downsamples the interior. `samplesRecorded`, `samplesRetained`, and `samplesCompacted` make that loss of interval resolution explicit. Every retained sample keeps cumulative counters, and its `delta` is recomputed over the retained interval. This compaction changes telemetry resolution only; it never changes frontier order or findings. +The enclosing `SharedObservabilityTelemetryV2` contains `schemaVersion`, `sampleIntervalStates`, `samplesRecorded`, `cadenceSamplesRecorded`, `samplesRetained`, `samplesCompacted`, `historyComplete`, `eventHistoryComplete`, `samples`, the existing schema-v1 `yieldSummary`, and schema-v2 `timing`. The two completeness flags carry separate meanings: `historyComplete` says whether the cadence ledger has its full prefix, while `eventHistoryComplete` says whether off-cadence event positions are reconstructable. Complete event history implies complete cadence history, but not conversely. -`RetentionBreakdownV1` separates `current` from per-field `peak` values. The current structural subset accounts for: +The default cadence remains every 10,000 completed transitions. Tests and embedders may request a different positive interval through `sharedObservabilityIntervalStates`; CLI users receive the fixed default. The four sample counters distinguish unique transition positions recorded before compaction, fixed-cadence boundaries crossed, retained entries, and entries removed by compaction. The ledger retains at most 128 samples and deterministically preserves the early and latest history while downsampling the interior. Compaction changes telemetry resolution only; it never changes frontier order, findings, or stop behavior. + +### Reasons and coalescing + +One transition can satisfy several triggers. Inkcheck retains one ledger sample for that pass-local state and coalesces all applicable reasons into this canonical order: + +1. `cadence` +2. `runtime_error` +3. `assertion_violation` +4. `goal_reached` +5. `stage_reached` +6. `authored_knot` +7. `visible_outcome` +8. `semantic_transition` +9. `terminal_variant` +10. `frontier_compaction` +11. `frontier_ceiling` +12. `checkpoint` +13. `epoch` +14. `pressure` +15. `termination` + +Reason order is schema data, not observation order. Repeated reasons are removed. Bit positions 0 through 14 in `triggerMask` map exactly to that exported canonical order, and the mask must equal the `reasons` vector: a reason is present exactly when its bit is set. `triggerYield` positions map to reason bits 1 through 8 in the same order: runtime errors, assertion violations, goals, stages, authored knots, visible outcomes, semantic transitions, and terminal variants. Every count is a nonnegative safe integer, and a tuple position is nonzero exactly when its corresponding reason is present. A cadence boundary that discovers one authored knot and one terminal variant therefore has reasons `["cadence", "authored_knot", "terminal_variant"]`, mask `289` (bits 0, 5, and 8), and trigger yield `[0, 0, 0, 0, 1, 0, 0, 1]`. Termination at an already sampled state adds `termination` and bit 14 to that same logical boundary rather than inventing a duplicate sample. + +The reason vector, mask, and trigger-yield tuple encode only the exact retained boundary. They are deliberately separate from `yield.delta`. Between adjacent sample sequences with complete event history, `triggerYield` must equal the semantic-category portion of that sample's interval delta exactly. After sample compaction creates a genuine sequence gap, Inkcheck rebuilds the aggregate delta across the wider interval between retained samples but preserves the ending boundary's original `reasons`, `triggerMask`, and `triggerYield`; incomplete v1 history has the same information limit. In those gap or incomplete-history cases, every trigger-yield count must be no greater than the matching aggregate delta count. A category count inside a widened interval therefore does not imply that the category triggered at its ending boundary. The redundant vector/mask/tuple contract makes that distinction fail-closed with a fixed bounded shape. + +This slice emits cadence, category, `frontier_compaction`, `frontier_ceiling`, and `termination` reasons. The `checkpoint`, `epoch`, and generic `pressure` values reserve stable vocabulary for later work; checkpoint save/reopen, epoch lifecycle, memory/time pressure, and other resource actions do not emit those boundaries yet. `frontier_ceiling` is the existing explicit shared-frontier binding event, not a general owner-pressure policy. + +The bounded ledger and the live callback have deliberately different rates. The ledger retains the event boundaries above and compacts them deterministically. `onSharedObservability` and CLI `resource` progress sample process memory only at the fixed cadence and final termination. A pending cadence observation is emitted only after its state is closed, so any same-state event or termination reason is already coalesced and append-only consumers never receive partial revisions. With the production cadence and 100-million-state ceiling, one uninterrupted shared-pass execution emits at most 10,001 live resource observations; category-heavy stories do not increase that bound. + +## Retention breakdown + +The retention breakdown separates `current` from per-field `peak` values. The current structural subset accounts for: - pending and active serialized state and variable payloads; - retained ancestry and node-table slots; @@ -21,13 +60,13 @@ The ledger retains at most 128 samples. If it grows beyond that bound, Inkcheck These are deterministic logical estimates compatible with the existing `sharedMemory` telemetry. Serialized strings use UTF-8 bytes and structural values use documented estimates. A peak object contains independent high-water values, so its components need not describe one simultaneous heap snapshot. -The subset is not complete owner attribution. It does not yet account for Ink runtime objects, checkpoint encode/decode buffers, report serialization, process-tree memory, or reserved finalization headroom. It must not be described as total memory owned by search. +Owner attribution remains incomplete. It does not yet account for Ink runtime objects, checkpoint encode/decode buffers, report merge/serialization state, process-tree memory, or reserved checkpoint/finalization headroom. The values must not be described as total search-owned memory, and this slice does not set owner budgets or apply pressure actions. -## Yield vector +## Yield vector and event timing -`YieldIntervalV1` reports separate cumulative and interval counts; it never produces a weighted usefulness score: +The yield ledger reports separate cumulative and interval counts; it never produces a weighted usefulness score: -| Category | Version-1 meaning | +| Category | Meaning | | --- | --- | | `critical` | Distinct runtime errors and assertion violations. | | `intent` | Approved goals and cumulative goal stages reached. | @@ -37,32 +76,43 @@ The subset is not complete owner attribution. It does not yet account for Ink ru | `terminalVariants` | Exact terminal states, kept separate from visible outcomes. | | `rawTerritory` | Transitions, unique exact states, and dedupe hits. These are work facts, not useful yield by themselves. | -`yieldSummary.firstUsefulAtState` marks the first critical, intent, authored-coverage, visible-outcome, or semantic-transition event. `firstCriticalAtState` is separate. `throughFirstUseful` and `afterFirstUseful` keep early value distinct from later yield without collapsing unlike categories. The existing discovery curve also samples assertion and goal changes after those trackers run, and visible-outcome-only changes are valid discovery boundaries. +`yieldSummary.firstUsefulAtState` marks the first critical, intent, authored-coverage, visible-outcome, or semantic-transition event. `firstCriticalAtState` is separate. `throughFirstUseful` and `afterFirstUseful` keep early value distinct from later yield without collapsing unlike categories. + +Telemetry v2 also exposes `timing` entries for `critical`, `intent`, `authoredCoverage`, `visibleOutcomes`, `semanticTransitions`, and `terminalVariants`. Each entry reports: + +- `identities` observed in that category; +- `firstAtState` and `lastAtState` transition positions; +- `longestDryStates` and `currentDryStates`; and +- `identitiesPerMillionTransitions`. + +These are deterministic transition distances and category-specific rates, not wall-clock timing, a plateau estimate, or a coverage claim. Raw territory deliberately has no useful-yield timing entry. The existing discovery curve still records its own bounded factual discovery history; observability reasons identify why a resource sample exists. -This slice does not yet expose kth/last event timing, full dry-gap history, rediscovery identities, throughput, retained GiB-minutes, campaign-new attribution, or checkpoint-, pressure-, and discovery-triggered samples. +This slice does not add kth-event milestones, campaign-new versus rediscovered identities, search-active-minute or retained-GiB-minute rates, or complete owner-cost attribution. ## Observed process memory -`onSharedObservability` and CLI `resource` progress events pair a deterministic sample with `ProcessMemoryObservationV1`. Each observation also carries numeric `runWideState`. For a standalone or general shared pass it equals the pass-local `sample.state`; for additive directed-goal work it adds the completed general-pass work. The CLI uses `runWideState` for monotonic outer progress and never rewrites the nested pass-local sample or infers an offset from the pass name. +`onSharedObservability` and CLI `resource` progress events expose `SharedResourceObservationV2`: a deterministic v2 sample paired with the existing `ProcessMemoryObservationV1`. Each observation also carries numeric `runWideState`. For a standalone or general shared pass it equals the pass-local `sample.state`; for additive directed-goal work it adds the completed general-pass work. The CLI uses `runWideState` for monotonic outer progress and never rewrites the nested pass-local sample or infers an offset from the pass name. + +Process fields include V8 heap used/total, process RSS, external and array-buffer bytes, compared logical accounted bytes, and an observational unattributed difference. The difference may be negative because the logical model and V8 heap measure different things. It is not proof of ownership or a leak. + +Process observations are deliberately excluded from shared checkpoints, canonical JSON reports, report/checkpoint IDs, exact-resume comparisons, and compact machine summaries. They appear only on live CLI progress and the bounded evidence-stream terminal resource summary. They cannot alter frontier order, sampling reasons, or policy because this slice activates no policy. + +## Resume and V1 migration -Process fields are: +The deterministic ledger is additive inside shared checkpoint schema v1. New checkpoints persist observability schema v2, including canonical sample reasons, exact trigger masks and boundary-local trigger-yield tuples, sequences, timing state, completeness markers, and the nested ledger self-check described below. For the same source, configuration, seeds, cadence, and prior event history, split execution produces the same search result, v2 ledger, and next checkpoint as uninterrupted execution. -- `heapUsedBytes` and `heapTotalBytes` from V8; -- process `rssBytes`; -- `externalBytes` and `arrayBuffersBytes`; -- `comparedLogicalAccountedBytes`; and -- `unattributedBytes`, calculated as `heapUsedBytes - comparedLogicalAccountedBytes`. +Checkpoint construction is side-effect-free: calling `checkpoint()` does not add or modify a sample, advance a sequence, or mutate timing. Checkpoint save/reopen likewise emits no observability boundary in this slice. Termination samples belong to finalized report telemetry and live progress, not the resumable pre-finalization checkpoint. Deterministic observability state participates in the logical checkpoint bytes and stable checkpoint ID; live process observations do not. -The difference may be negative because the logical model and V8 heap measure different things. It is an observational comparison, not proof of ownership or a leak. Heap/RSS values vary with runtime, garbage collection, machine load, and Node version. +Every emitted `SharedObservabilityCheckpointV2` carries `integritySha256`, a 64-character lowercase SHA-256 self-check. Inkcheck validates the exact known v2 ledger shape, reconstructs every fixed field and nested sample/count/timing field in schema-defined order, omits `integritySha256` from that body, prefixes the domain separator `inkcheck:shared-observability-checkpoint:v2\0`, and hashes the resulting canonical JSON bytes. Source object-key insertion order therefore cannot change the digest: accepted reordered input is emitted again in canonical order. A missing, stale, malformed, or accidentally mutated ledger fails closed instead of being resumed. -For that reason, process observations are deliberately excluded from shared checkpoints, canonical JSON reports, report/checkpoint IDs, exact-resume comparisons, and compact machine summaries. They appear only on live CLI progress and the bounded `--json-stream` terminal resource summary. Turning these values into automatic stopping, eviction, or allocation decisions is outside this slice. +This checksum binds the logical ledger fields together; it is not authentication, a signature, or a MAC. A writer able to alter the checkpoint can recompute it. For persisted local artifacts, the manifest digest and full stored-payload digest remain the external file-corruption/readback boundary before bounded decompression; `integritySha256` is the additional internal self-check on the decoded observability ledger. It is persisted checkpoint state only and is not added to live resource progress or report telemetry. -## Resume and compatibility +Older shared-checkpoint-schema-v1 artifacts remain readable. A checkpoint with observability schema v1 is deterministically migrated to v2 when resumed. Migration derives each historical reason vector and trigger mask only from the explicit v1 boundary (`interval`, `termination`, or their combination), never from a rebuilt aggregate yield delta. Because v1 did not retain exact category triggers at that boundary, migration sets all eight `triggerYield` positions to zero. Those zeros mean “no v1 category trigger was recorded”; they are not proof that no category event occurred within the interval. The existing fixed-cadence history remains usable, but v1 did not record category/frontier reason history or the event positions needed for every timing field. Migrated telemetry therefore uses `eventHistoryComplete: false`. At the migration boundary, identity counts remain calculable from cumulative yield and `identitiesPerMillionTransitions` remains calculable once the completed-transition count is nonzero; `firstAtState`, `lastAtState`, `longestDryStates`, and `currentDryStates` are `null` because the old artifact cannot prove them. Subsequent v2 events may populate position and current-gap facts that become knowable after reopening, but unknown prefix facts remain `null`. Inkcheck does not reconstruct or guess missing events. `historyComplete` continues to distinguish a checkpoint that predates the ledger entirely. Neither a v1 artifact nor a pre-ledger artifact contains the nested checksum; the first v2 checkpoint emitted after either migration carries the canonical `integritySha256`. Newly observed v2 events after the reopen remain exact, but they do not retroactively make the missing prefix complete. -The deterministic ledger is additive inside shared checkpoint schema v1. A split run with the same source, configuration, seed, and sampling interval produces the same search result, ledger, and next checkpoint as one uninterrupted run. Saving a checkpoint preserves the latest fixed-cadence ledger state but does not create a checkpoint-boundary sample. Changing the interval while resuming fails closed. +Changing the sampling interval while resuming still fails closed. Sequence validation requires positive strictly increasing values bounded by the total record count, state distance, cadence count, and possible records remaining; un-compacted ledgers must be contiguous and the latest retained sequence must equal `samplesRecorded`. Compacted gaps are expected: these relational checks do not claim to reconstruct or replay the exact discarded compaction history. Noncanonical or incompatible reasons, vector/mask/tuple disagreement, an inexact adjacent complete-history trigger delta, an over-large gap/incomplete-history trigger delta, inconsistent counters, an invalid integrity self-check, and impossible timing/yield relationships also fail closed. This migration changes nested observability data, not the shared checkpoint's top-level schema version or deterministic search semantics. -Resumable checkpoints contain interval samples only; termination samples belong to finalized report telemetry and live progress. For checkpoint state `S`, ledger base `B`, and cadence `I`, `samplesRecorded` is exactly `floor(S / I) - floor(B / I)`. Retained samples stay on cadence boundaries, preserve the first boundary after `B` and the latest completed boundary through `S`, and keep transition counters aligned with their sample/cursor states. Milestone fields must agree with cumulative critical/useful yield: samples before the first-useful state cannot already contain useful yield, and the milestone vector cannot exceed any retained cumulative sample at or after that state. A complete history has base state zero. These cross-field checks fail closed on tampering while still permitting useful evidence discovered before the first retained sample. +## Scope and privacy -Older schema-v1 checkpoints remain readable. Because they contain no historical interval ledger or meaningful-transition counter, resumed telemetry sets `historyComplete: false` and begins interval deltas from the reopen boundary. Search frontier and finding compatibility remain exact; Inkcheck does not invent missing historical telemetry. +Samples and live resource events contain only aggregate counts, boundary-local numeric trigger counts, byte estimates, pass names, reason codes and their bounded numeric mask, transition positions, and process values. They contain no story source, choice prose, final text, variable names or values, runtime messages, or witness paths. -Samples and live resource events contain only aggregate counts, byte estimates, pass names, and process values. They contain no story source, choice prose, final text, variable names or values, runtime messages, or witness paths. +Checkpoint/epoch/pressure boundaries, complete owner accounting, retained-GiB-minute cost, and any observability-driven allocation, eviction, compaction, or stopping policy remain future #216/#156/#217/#218 work. The earlier V1 overhead study is tied to its measured source heads; it is not an overhead claim for this exact V2 implementation head. A separate exact-head calibration is required before any performance or promotion claim. diff --git a/package.json b/package.json index be92354..eef1806 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "docs/report-schema-v1.md", "docs/machine-output.md", "docs/evidence-ndjson.md", + "docs/progress-ndjson.md", "docs/local-artifacts.md", "docs/local-checkpoints.md", "docs/shared-checkpoint-schema-v1.md", diff --git a/src/cli.ts b/src/cli.ts index 2255c2b..2fb8727 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,7 +19,7 @@ import { ExploreResult, ExploreProgress, ExploreEvidence, - SharedResourceObservationV1, + SharedResourceObservation, DEFAULT_STORY_SEED, MAX_STORY_SEED, PortfolioWeights, @@ -770,7 +770,7 @@ async function main() { }); let sequence = 0; let statesExplored = resumed?.checkpoint.state.statesExplored ?? 0; - let latestSharedObservation: SharedResourceObservationV1 | undefined; + let latestSharedObservation: SharedResourceObservation | undefined; const selectedProgressMode = progressMode as "auto" | "human" | "ndjson" | "off"; const humanProgress = selectedProgressMode === "auto" || selectedProgressMode === "human" ? new HumanProgressRenderer(process.stderr, selectedProgressMode) @@ -810,7 +810,7 @@ async function main() { statesSinceLastDiscovery?: number | null; knotsVisited?: number; discoveries?: DiscoveryChanges; - sharedObservability?: SharedResourceObservationV1; + sharedObservability?: SharedResourceObservation; status?: ProgressStatus; stopReason?: ProgressStopReason; outcome?: ProgressOutcome; @@ -986,7 +986,7 @@ async function main() { randomnessDetected: semantics.usesRandomness, ...(asJsonStream ? { onEvidence: streamEvidence } : {}), ...(selectedProgressMode === "off" ? {} : { - onSharedObservability: (observation: SharedResourceObservationV1) => { + onSharedObservability: (observation: SharedResourceObservation) => { latestSharedObservation = observation; statesExplored = saveCheckpoint ? observation.runWideState diff --git a/src/explore.ts b/src/explore.ts index 43c32db..b367c2f 100644 --- a/src/explore.ts +++ b/src/explore.ts @@ -793,11 +793,13 @@ export interface ExploreOptions { /** Receive replayable findings without waiting for the final report. */ onEvidence?: (evidence: ExploreEvidence) => void; /** - * Shared search only: receive process-scoped heap/RSS beside each bounded, - * deterministic resource/yield sample. Process values are observational and + * Shared search only: receive process-scoped heap/RSS at the deterministic + * cadence and final termination boundary. The bounded report/checkpoint + * ledger records additional event boundaries without turning each discovery + * into process sampling or live output. Process values are observational and * are deliberately excluded from checkpoints and canonical reports. */ - onSharedObservability?: (observation: SharedResourceObservationV1) => void; + onSharedObservability?: (observation: SharedResourceObservation) => void; /** Internal/test override for the normal 10,000-transition sample cadence. */ sharedObservabilityIntervalStates?: number; /** Internal/test override for the normal 10,000-state progress cadence. */ @@ -951,7 +953,7 @@ export interface PassTelemetry { /** Shared search only: deterministic retained-payload accounting, not process heap usage. */ sharedMemory?: SharedMemoryTelemetry; /** Shared search only: bounded deterministic retention and category-specific yield samples. */ - sharedObservability?: SharedObservabilityTelemetryV1; + sharedObservability?: SharedObservabilityTelemetryV1 | SharedObservabilityTelemetryV2; /** Shared search only: distinct variable snapshots observed. */ variableStatesObserved?: number; /** Shared search only: distinct variable changes observed. */ @@ -1043,6 +1045,79 @@ export interface ResourceSampleV1 { yield: YieldIntervalV1; } +export const SHARED_OBSERVABILITY_REASON_ORDER_V2 = [ + "cadence", + "runtime_error", + "assertion_violation", + "goal_reached", + "stage_reached", + "authored_knot", + "visible_outcome", + "semantic_transition", + "terminal_variant", + "frontier_compaction", + "frontier_ceiling", + "checkpoint", + "epoch", + "pressure", + "termination", +] as const; + +export type SharedObservabilityReasonV2 = typeof SHARED_OBSERVABILITY_REASON_ORDER_V2[number]; + +/** + * Exact semantic increments at one logical boundary. Tuple positions map to + * reason bits 1..8: runtime error through terminal variant. + */ +export type SharedObservabilityTriggerYieldV2 = [ + runtimeErrors: number, + assertionViolations: number, + goalsReached: number, + stagesReached: number, + authoredKnots: number, + visibleOutcomes: number, + semanticTransitions: number, + terminalVariants: number, +]; + +export interface ResourceSampleV2 { + schemaVersion: 2; + /** Monotonic record number; coalescing another reason at this state does not advance it. */ + sequence: number; + /** Canonically ordered, duplicate-free reasons observed at this transition position. */ + reasons: SharedObservabilityReasonV2[]; + /** Bit-for-bit trigger fingerprint using SHARED_OBSERVABILITY_REASON_ORDER_V2 indices. */ + triggerMask: number; + /** Exact semantic yield increments at this boundary; compaction never widens this tuple. */ + triggerYield: SharedObservabilityTriggerYieldV2; + state: number; + retention: RetentionBreakdownV1; + yield: YieldIntervalV1; +} + +export interface SharedYieldTimingCategoryV2 { + /** Distinct identities observed in this category through the boundary. */ + identities: number; + firstAtState: number | null; + lastAtState: number | null; + /** Null when migration cannot reconstruct the earlier event history. */ + longestDryStates: number | null; + /** Null until a trustworthy last event position is known. */ + currentDryStates: number | null; + /** Deterministic category identities per million completed transitions. */ + identitiesPerMillionTransitions: number | null; +} + +export interface SharedYieldTimingV2 { + schemaVersion: 2; + critical: SharedYieldTimingCategoryV2; + intent: SharedYieldTimingCategoryV2; + authoredCoverage: SharedYieldTimingCategoryV2; + visibleOutcomes: SharedYieldTimingCategoryV2; + semanticTransitions: SharedYieldTimingCategoryV2; + terminalVariants: SharedYieldTimingCategoryV2; +} + export interface SharedYieldPhaseSummaryV1 { schemaVersion: 1; /** First event in critical, intent, authored coverage, visible outcome, or semantic-transition categories. */ @@ -1067,6 +1142,24 @@ export interface SharedObservabilityTelemetryV1 { yieldSummary: SharedYieldPhaseSummaryV1; } +export interface SharedObservabilityTelemetryV2 { + schemaVersion: 2; + sampleIntervalStates: number; + /** Unique transition positions recorded before deterministic compaction. */ + samplesRecorded: number; + /** Fixed-cadence boundaries crossed; remains independently auditable. */ + cadenceSamplesRecorded: number; + samplesRetained: number; + samplesCompacted: number; + /** False only when resuming a checkpoint that predates the cadence ledger. */ + historyComplete: boolean; + /** False when migration cannot reconstruct prior off-cadence event positions. */ + eventHistoryComplete: boolean; + samples: ResourceSampleV2[]; + yieldSummary: SharedYieldPhaseSummaryV1; + timing: SharedYieldTimingV2; +} + /** Live-only observation. The process fields never enter exact resume/report identity. */ export interface SharedResourceObservationV1 { schemaVersion: 1; @@ -1077,6 +1170,17 @@ export interface SharedResourceObservationV1 { process: ProcessMemoryObservationV1; } +/** Live-only V2 observation; process fields remain outside deterministic identity. */ +export interface SharedResourceObservationV2 { + schemaVersion: 2; + pass: string; + runWideState: number; + sample: ResourceSampleV2; + process: ProcessMemoryObservationV1; +} + +export type SharedResourceObservation = SharedResourceObservationV1 | SharedResourceObservationV2; + function emptySharedYieldCounts(): SharedYieldCountsV1 { return { critical: { runtimeErrors: 0, assertionViolations: 0 }, @@ -1138,6 +1242,202 @@ function usefulYieldObserved(value: SharedYieldCountsV1): boolean { || value.semanticTransitions > 0; } +const SHARED_OBSERVABILITY_REASON_RANK_V2 = new Map( + SHARED_OBSERVABILITY_REASON_ORDER_V2.map((reason, index) => [reason, index]) +); +const SHARED_OBSERVABILITY_TRIGGER_MASK_MAX_V2 = 2 ** SHARED_OBSERVABILITY_REASON_ORDER_V2.length - 1; + +function canonicalSharedObservabilityReasonsV2( + reasons: Iterable +): SharedObservabilityReasonV2[] { + return [...new Set(reasons)].sort((left, right) => + SHARED_OBSERVABILITY_REASON_RANK_V2.get(left)! - SHARED_OBSERVABILITY_REASON_RANK_V2.get(right)!); +} + +function sharedObservabilityTriggerMaskV2( + reasons: Iterable +): number { + let mask = 0; + for (const reason of reasons) { + mask |= 1 << SHARED_OBSERVABILITY_REASON_RANK_V2.get(reason)!; + } + return mask; +} + +function emptySharedObservabilityTriggerYieldV2(): SharedObservabilityTriggerYieldV2 { + return [0, 0, 0, 0, 0, 0, 0, 0]; +} + +function sharedObservabilityTriggerYieldV2( + delta: SharedYieldCountsV1 +): SharedObservabilityTriggerYieldV2 { + return [ + delta.critical.runtimeErrors, + delta.critical.assertionViolations, + delta.intent.goalsReached, + delta.intent.stagesReached, + delta.authoredCoverage.knotsVisited, + delta.visibleOutcomes, + delta.semanticTransitions, + delta.terminalVariants, + ]; +} + +function addSharedObservabilityTriggerYieldV2( + left: SharedObservabilityTriggerYieldV2, + right: SharedObservabilityTriggerYieldV2 +): SharedObservabilityTriggerYieldV2 { + return [ + left[0] + right[0], + left[1] + right[1], + left[2] + right[2], + left[3] + right[3], + left[4] + right[4], + left[5] + right[5], + left[6] + right[6], + left[7] + right[7], + ]; +} + +function validSharedObservabilityTriggerYieldV2( + value: unknown +): value is SharedObservabilityTriggerYieldV2 { + return Array.isArray(value) && value.length === 8 + && value.every((count) => Number.isSafeInteger(count) && count >= 0); +} + +function sharedObservabilityReasonsForTriggerYieldV2( + value: SharedObservabilityTriggerYieldV2 +): SharedObservabilityReasonV2[] { + const reasons: SharedObservabilityReasonV2[] = []; + for (let index = 0; index < value.length; index++) { + if (value[index] > 0) reasons.push(SHARED_OBSERVABILITY_REASON_ORDER_V2[index + 1]); + } + return reasons; +} + +function sharedObservabilityTriggerYieldAtMostV2( + value: SharedObservabilityTriggerYieldV2, + interval: SharedYieldCountsV1 +): boolean { + const intervalVector = sharedObservabilityTriggerYieldV2(interval); + return value.every((count, index) => count <= intervalVector[index]); +} + +function sharedObservabilityTriggerYieldEqualV2( + value: SharedObservabilityTriggerYieldV2, + interval: SharedYieldCountsV1 +): boolean { + const intervalVector = sharedObservabilityTriggerYieldV2(interval); + return value.every((count, index) => count === intervalVector[index]); +} + +function sharedObservabilityReasonsForYieldDeltaV2( + delta: SharedYieldCountsV1 +): SharedObservabilityReasonV2[] { + const reasons: SharedObservabilityReasonV2[] = []; + if (delta.critical.runtimeErrors > 0) reasons.push("runtime_error"); + if (delta.critical.assertionViolations > 0) reasons.push("assertion_violation"); + if (delta.intent.goalsReached > 0) reasons.push("goal_reached"); + if (delta.intent.stagesReached > 0) reasons.push("stage_reached"); + if (delta.authoredCoverage.knotsVisited > 0) reasons.push("authored_knot"); + if (delta.visibleOutcomes > 0) reasons.push("visible_outcome"); + if (delta.semanticTransitions > 0) reasons.push("semantic_transition"); + if (delta.terminalVariants > 0) reasons.push("terminal_variant"); + return reasons; +} + +type SharedYieldTimingKeyV2 = Exclude; + +const SHARED_YIELD_TIMING_KEYS_V2: SharedYieldTimingKeyV2[] = [ + "critical", "intent", "authoredCoverage", "visibleOutcomes", "semanticTransitions", "terminalVariants", +]; + +function sharedYieldCategoryIdentitiesV2( + value: SharedYieldCountsV1, + key: SharedYieldTimingKeyV2 +): number { + switch (key) { + case "critical": return value.critical.runtimeErrors + value.critical.assertionViolations; + case "intent": return value.intent.goalsReached + value.intent.stagesReached; + case "authoredCoverage": return value.authoredCoverage.knotsVisited; + case "visibleOutcomes": return value.visibleOutcomes; + case "semanticTransitions": return value.semanticTransitions; + case "terminalVariants": return value.terminalVariants; + } +} + +function emptySharedYieldTimingCategoryV2(): SharedYieldTimingCategoryV2 { + return { + identities: 0, + firstAtState: null, + lastAtState: null, + longestDryStates: 0, + currentDryStates: null, + identitiesPerMillionTransitions: null, + }; +} + +function emptySharedYieldTimingV2(): SharedYieldTimingV2 { + return { + schemaVersion: 2, + critical: emptySharedYieldTimingCategoryV2(), + intent: emptySharedYieldTimingCategoryV2(), + authoredCoverage: emptySharedYieldTimingCategoryV2(), + visibleOutcomes: emptySharedYieldTimingCategoryV2(), + semanticTransitions: emptySharedYieldTimingCategoryV2(), + terminalVariants: emptySharedYieldTimingCategoryV2(), + }; +} + +function cloneSharedYieldTimingV2(value: SharedYieldTimingV2): SharedYieldTimingV2 { + return { + schemaVersion: 2, + critical: { ...value.critical }, + intent: { ...value.intent }, + authoredCoverage: { ...value.authoredCoverage }, + visibleOutcomes: { ...value.visibleOutcomes }, + semanticTransitions: { ...value.semanticTransitions }, + terminalVariants: { ...value.terminalVariants }, + }; +} + +function timingRateV2(identities: number, state: number): number | null { + return state === 0 ? null : identities * 1_000_000 / state; +} + +function snapshotSharedYieldTimingV2( + value: SharedYieldTimingV2, + state: number +): SharedYieldTimingV2 { + const result = cloneSharedYieldTimingV2(value); + for (const key of SHARED_YIELD_TIMING_KEYS_V2) { + const category = result[key]; + category.currentDryStates = category.lastAtState === null ? null : state - category.lastAtState; + if (category.longestDryStates !== null && category.currentDryStates !== null) { + category.longestDryStates = Math.max(category.longestDryStates, category.currentDryStates); + } + category.identitiesPerMillionTransitions = timingRateV2(category.identities, state); + } + return result; +} + +function migratedSharedYieldTimingV2(value: SharedYieldCountsV1, state: number): SharedYieldTimingV2 { + const result = emptySharedYieldTimingV2(); + for (const key of SHARED_YIELD_TIMING_KEYS_V2) { + const identities = sharedYieldCategoryIdentitiesV2(value, key); + result[key] = { + identities, + firstAtState: null, + lastAtState: null, + longestDryStates: null, + currentDryStates: null, + identitiesPerMillionTransitions: timingRateV2(identities, state), + }; + } + return result; +} + export interface DiscoveryCounts { endingsFound: number; runtimeErrorsFound: number; @@ -1846,6 +2146,36 @@ export interface SharedObservabilityCheckpointV1 { historyComplete: boolean; } +export interface SharedObservabilityCheckpointV2 { + schemaVersion: 2; + sampleIntervalStates: number; + samplesRecorded: number; + cadenceSamplesRecorded: number; + samples: ResourceSampleV2[]; + baseState: number; + baseYield: SharedYieldCountsV1; + previousSampleState: number; + previousYield: SharedYieldCountsV1; + nextSampleState: number; + firstUsefulAtState: number | null; + firstCriticalAtState: number | null; + throughFirstUseful: SharedYieldCountsV1; + historyComplete: boolean; + eventHistoryComplete: boolean; + timing: SharedYieldTimingV2; + /** + * Canonical SHA-256 self-check over every preceding V2 ledger field. This + * detects stale or accidental mutation; it is not authentication because a + * writer that can change the checkpoint can also recompute the digest. + */ + integritySha256: string; +} + +type SharedObservabilityCheckpointV2Body = Omit< + SharedObservabilityCheckpointV2, + "integritySha256" +>; + function cloneJsonValue(value: T): T { if (Array.isArray(value)) { return value.map((item) => item === undefined ? null : cloneJsonValue(item)) as T; @@ -1921,7 +2251,7 @@ export interface SharedSearchCheckpoint { ancestryPayloadBytes: number; peakRetainedMemory: SharedRetainedMemory; /** Additive deterministic ledger; live heap/RSS observations are never persisted. */ - sharedObservability?: SharedObservabilityCheckpointV1; + sharedObservability?: SharedObservabilityCheckpointV1 | SharedObservabilityCheckpointV2; }; } @@ -2018,6 +2348,307 @@ const SHARED_RETAINED_BYTE_KEYS: Array = [ "ancestryBytes", "dedupeBytes", "semanticIndexBytes", "frontierReferenceBytes", "findingBytes", ]; +const SHARED_OBSERVABILITY_CHECKPOINT_V2_BODY_KEYS = [ + "schemaVersion", "sampleIntervalStates", "samplesRecorded", "cadenceSamplesRecorded", "samples", + "baseState", "baseYield", "previousSampleState", "previousYield", "nextSampleState", + "firstUsefulAtState", "firstCriticalAtState", "throughFirstUseful", "historyComplete", + "eventHistoryComplete", "timing", +] as const; +const SHARED_OBSERVABILITY_CHECKPOINT_V2_KEYS = [ + ...SHARED_OBSERVABILITY_CHECKPOINT_V2_BODY_KEYS, + "integritySha256", +] as const; +const SHARED_OBSERVABILITY_SAMPLE_V2_KEYS = [ + "schemaVersion", "sequence", "reasons", "triggerMask", "triggerYield", "state", "retention", "yield", +] as const; +const SHARED_OBSERVABILITY_RETENTION_V1_KEYS = [ + "schemaVersion", "current", "peak", "releasedNodes", "frontierCompactions", +] as const; +const SHARED_OBSERVABILITY_YIELD_INTERVAL_V1_KEYS = [ + "schemaVersion", "fromStateExclusive", "throughState", "delta", "cumulative", +] as const; +const SHARED_YIELD_COUNTS_V1_KEYS = [ + "critical", "intent", "authoredCoverage", "visibleOutcomes", "semanticTransitions", + "terminalVariants", "rawTerritory", +] as const; +const SHARED_YIELD_CRITICAL_V1_KEYS = ["runtimeErrors", "assertionViolations"] as const; +const SHARED_YIELD_INTENT_V1_KEYS = ["goalsReached", "stagesReached"] as const; +const SHARED_YIELD_AUTHORED_V1_KEYS = ["knotsVisited"] as const; +const SHARED_YIELD_RAW_TERRITORY_V1_KEYS = ["transitions", "uniqueStates", "dedupeHits"] as const; +const SHARED_YIELD_TIMING_V2_KEYS = [ + "schemaVersion", ...SHARED_YIELD_TIMING_KEYS_V2, +] as const; +const SHARED_YIELD_TIMING_CATEGORY_V2_KEYS = [ + "identities", "firstAtState", "lastAtState", "longestDryStates", "currentDryStates", + "identitiesPerMillionTransitions", +] as const; +const SHARED_OBSERVABILITY_CHECKPOINT_V2_INTEGRITY_DOMAIN = + "inkcheck:shared-observability-checkpoint:v2\0"; + +function exactCanonicalRecord( + value: unknown, + keys: readonly string[] +): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return false; + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.length !== keys.length || ownKeys.some((key) => typeof key !== "string")) return false; + const expected = new Set(keys); + for (const key of ownKeys as string[]) { + if (!expected.has(key)) return false; + } + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) return false; + } + return true; +} + +function boundedDenseCanonicalArray(value: unknown, maximum: number): value is unknown[] { + if (!Array.isArray(value) || value.length > maximum) return false; + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.length !== value.length + 1 || !ownKeys.includes("length")) return false; + for (let index = 0; index < value.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) return false; + } + return ownKeys.every((key) => key === "length" + || (typeof key === "string" && /^(?:0|[1-9]\d*)$/.test(key) && Number(key) < value.length)); +} + +function canonicalCheckpointNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0); +} + +function canonicalCheckpointNullableNumber(value: unknown): value is number | null { + return value === null || canonicalCheckpointNumber(value); +} + +function orderedSharedYieldCountsV1(value: unknown): SharedYieldCountsV1 | undefined { + if (!exactCanonicalRecord(value, SHARED_YIELD_COUNTS_V1_KEYS) + || !exactCanonicalRecord(value.critical, SHARED_YIELD_CRITICAL_V1_KEYS) + || !exactCanonicalRecord(value.intent, SHARED_YIELD_INTENT_V1_KEYS) + || !exactCanonicalRecord(value.authoredCoverage, SHARED_YIELD_AUTHORED_V1_KEYS) + || !exactCanonicalRecord(value.rawTerritory, SHARED_YIELD_RAW_TERRITORY_V1_KEYS)) { + return undefined; + } + const numbers = [ + value.critical.runtimeErrors, value.critical.assertionViolations, + value.intent.goalsReached, value.intent.stagesReached, value.authoredCoverage.knotsVisited, + value.visibleOutcomes, value.semanticTransitions, value.terminalVariants, + value.rawTerritory.transitions, value.rawTerritory.uniqueStates, value.rawTerritory.dedupeHits, + ]; + if (!numbers.every(canonicalCheckpointNumber)) return undefined; + return { + critical: { + runtimeErrors: value.critical.runtimeErrors, + assertionViolations: value.critical.assertionViolations, + }, + intent: { + goalsReached: value.intent.goalsReached, + stagesReached: value.intent.stagesReached, + }, + authoredCoverage: { knotsVisited: value.authoredCoverage.knotsVisited }, + visibleOutcomes: value.visibleOutcomes, + semanticTransitions: value.semanticTransitions, + terminalVariants: value.terminalVariants, + rawTerritory: { + transitions: value.rawTerritory.transitions, + uniqueStates: value.rawTerritory.uniqueStates, + dedupeHits: value.rawTerritory.dedupeHits, + }, + } as SharedYieldCountsV1; +} + +function orderedSharedRetainedMemory(value: unknown): SharedRetainedMemory | undefined { + if (!exactCanonicalRecord(value, SHARED_RETAINED_MEMORY_KEYS) + || !SHARED_RETAINED_MEMORY_KEYS.every((key) => canonicalCheckpointNumber(value[key]))) { + return undefined; + } + return Object.fromEntries( + SHARED_RETAINED_MEMORY_KEYS.map((key) => [key, value[key]]) + ) as unknown as SharedRetainedMemory; +} + +function orderedRetentionBreakdownV1(value: unknown): RetentionBreakdownV1 | undefined { + if (!exactCanonicalRecord(value, SHARED_OBSERVABILITY_RETENTION_V1_KEYS) + || !canonicalCheckpointNumber(value.schemaVersion) + || !canonicalCheckpointNumber(value.releasedNodes) + || !canonicalCheckpointNumber(value.frontierCompactions)) { + return undefined; + } + const current = orderedSharedRetainedMemory(value.current); + const peak = orderedSharedRetainedMemory(value.peak); + if (!current || !peak) return undefined; + return { + schemaVersion: value.schemaVersion as 1, + current, + peak, + releasedNodes: value.releasedNodes, + frontierCompactions: value.frontierCompactions, + }; +} + +function orderedYieldIntervalV1(value: unknown): YieldIntervalV1 | undefined { + if (!exactCanonicalRecord(value, SHARED_OBSERVABILITY_YIELD_INTERVAL_V1_KEYS) + || !canonicalCheckpointNumber(value.schemaVersion) + || !canonicalCheckpointNumber(value.fromStateExclusive) + || !canonicalCheckpointNumber(value.throughState)) { + return undefined; + } + const delta = orderedSharedYieldCountsV1(value.delta); + const cumulative = orderedSharedYieldCountsV1(value.cumulative); + if (!delta || !cumulative) return undefined; + return { + schemaVersion: value.schemaVersion as 1, + fromStateExclusive: value.fromStateExclusive, + throughState: value.throughState, + delta, + cumulative, + }; +} + +function orderedResourceSampleV2(value: unknown): ResourceSampleV2 | undefined { + if (!exactCanonicalRecord(value, SHARED_OBSERVABILITY_SAMPLE_V2_KEYS) + || !canonicalCheckpointNumber(value.schemaVersion) + || !canonicalCheckpointNumber(value.sequence) + || !canonicalCheckpointNumber(value.triggerMask) + || !canonicalCheckpointNumber(value.state) + || !boundedDenseCanonicalArray(value.reasons, SHARED_OBSERVABILITY_REASON_ORDER_V2.length) + || value.reasons.length === 0 + || !value.reasons.every((reason) => typeof reason === "string" && reason.length <= 64) + || !boundedDenseCanonicalArray(value.triggerYield, 8) + || value.triggerYield.length !== 8 + || !value.triggerYield.every(canonicalCheckpointNumber)) { + return undefined; + } + const retention = orderedRetentionBreakdownV1(value.retention); + const yieldInterval = orderedYieldIntervalV1(value.yield); + if (!retention || !yieldInterval) return undefined; + return { + schemaVersion: value.schemaVersion as 2, + sequence: value.sequence, + reasons: [...value.reasons] as SharedObservabilityReasonV2[], + triggerMask: value.triggerMask, + triggerYield: [...value.triggerYield] as SharedObservabilityTriggerYieldV2, + state: value.state, + retention, + yield: yieldInterval, + }; +} + +function orderedSharedYieldTimingCategoryV2( + value: unknown +): SharedYieldTimingCategoryV2 | undefined { + if (!exactCanonicalRecord(value, SHARED_YIELD_TIMING_CATEGORY_V2_KEYS) + || !canonicalCheckpointNumber(value.identities) + || !canonicalCheckpointNullableNumber(value.firstAtState) + || !canonicalCheckpointNullableNumber(value.lastAtState) + || !canonicalCheckpointNullableNumber(value.longestDryStates) + || !canonicalCheckpointNullableNumber(value.currentDryStates) + || !canonicalCheckpointNullableNumber(value.identitiesPerMillionTransitions)) { + return undefined; + } + return { + identities: value.identities, + firstAtState: value.firstAtState, + lastAtState: value.lastAtState, + longestDryStates: value.longestDryStates, + currentDryStates: value.currentDryStates, + identitiesPerMillionTransitions: value.identitiesPerMillionTransitions, + }; +} + +function orderedSharedYieldTimingV2(value: unknown): SharedYieldTimingV2 | undefined { + if (!exactCanonicalRecord(value, SHARED_YIELD_TIMING_V2_KEYS) + || !canonicalCheckpointNumber(value.schemaVersion)) { + return undefined; + } + const categories = SHARED_YIELD_TIMING_KEYS_V2.map((key) => ( + orderedSharedYieldTimingCategoryV2(value[key]) + )); + if (categories.some((category) => category === undefined)) return undefined; + return { + schemaVersion: value.schemaVersion as 2, + critical: categories[0]!, + intent: categories[1]!, + authoredCoverage: categories[2]!, + visibleOutcomes: categories[3]!, + semanticTransitions: categories[4]!, + terminalVariants: categories[5]!, + }; +} + +function orderedSharedObservabilityCheckpointV2Body( + value: unknown, + expectIntegrity: boolean +): SharedObservabilityCheckpointV2Body | undefined { + const keys = expectIntegrity + ? SHARED_OBSERVABILITY_CHECKPOINT_V2_KEYS + : SHARED_OBSERVABILITY_CHECKPOINT_V2_BODY_KEYS; + if (!exactCanonicalRecord(value, keys) + || !canonicalCheckpointNumber(value.schemaVersion) + || !canonicalCheckpointNumber(value.sampleIntervalStates) + || !canonicalCheckpointNumber(value.samplesRecorded) + || !canonicalCheckpointNumber(value.cadenceSamplesRecorded) + || !canonicalCheckpointNumber(value.baseState) + || !canonicalCheckpointNumber(value.previousSampleState) + || !canonicalCheckpointNumber(value.nextSampleState) + || !canonicalCheckpointNullableNumber(value.firstUsefulAtState) + || !canonicalCheckpointNullableNumber(value.firstCriticalAtState) + || typeof value.historyComplete !== "boolean" + || typeof value.eventHistoryComplete !== "boolean" + || !boundedDenseCanonicalArray(value.samples, MAX_SHARED_OBSERVABILITY_SAMPLES)) { + return undefined; + } + const samples: ResourceSampleV2[] = []; + for (const sampleValue of value.samples) { + const sample = orderedResourceSampleV2(sampleValue); + if (!sample) return undefined; + samples.push(sample); + } + const baseYield = orderedSharedYieldCountsV1(value.baseYield); + const previousYield = orderedSharedYieldCountsV1(value.previousYield); + const throughFirstUseful = orderedSharedYieldCountsV1(value.throughFirstUseful); + const timing = orderedSharedYieldTimingV2(value.timing); + if (!baseYield || !previousYield || !throughFirstUseful || !timing) return undefined; + return { + schemaVersion: value.schemaVersion as 2, + sampleIntervalStates: value.sampleIntervalStates, + samplesRecorded: value.samplesRecorded, + cadenceSamplesRecorded: value.cadenceSamplesRecorded, + samples, + baseState: value.baseState, + baseYield, + previousSampleState: value.previousSampleState, + previousYield, + nextSampleState: value.nextSampleState, + firstUsefulAtState: value.firstUsefulAtState, + firstCriticalAtState: value.firstCriticalAtState, + throughFirstUseful, + historyComplete: value.historyComplete, + eventHistoryComplete: value.eventHistoryComplete, + timing, + }; +} + +function sharedObservabilityCheckpointV2IntegritySha256( + value: unknown, + expectIntegrity: boolean +): string | undefined { + try { + const ordered = orderedSharedObservabilityCheckpointV2Body(value, expectIntegrity); + if (!ordered) return undefined; + return createHash("sha256") + .update(SHARED_OBSERVABILITY_CHECKPOINT_V2_INTEGRITY_DOMAIN) + .update(JSON.stringify(ordered)) + .digest("hex"); + } catch { + return undefined; + } +} + function validSharedYieldCounts(value: SharedYieldCountsV1 | undefined): boolean { if (!value || !value.critical || !value.intent || !value.authoredCoverage || !value.rawTerritory) return false; return [ @@ -2062,6 +2693,67 @@ function sharedRetainedCurrentTotalIsExact(value: SharedRetainedMemory): boolean return Number.isSafeInteger(total) && value.totalAccountedBytes === total; } +function validSharedObservabilityReasonsV2(value: unknown): value is SharedObservabilityReasonV2[] { + if (!Array.isArray(value) || value.length === 0 || value.length > SHARED_OBSERVABILITY_REASON_ORDER_V2.length) { + return false; + } + let priorRank = -1; + for (const reason of value) { + if (typeof reason !== "string") return false; + const rank = SHARED_OBSERVABILITY_REASON_RANK_V2.get(reason as SharedObservabilityReasonV2); + if (rank === undefined || rank <= priorRank) return false; + priorRank = rank; + } + return true; +} + +function validSharedYieldTimingV2( + timing: SharedYieldTimingV2 | undefined, + cumulative: SharedYieldCountsV1, + state: number, + eventHistoryComplete: boolean +): boolean { + if (!timing || timing.schemaVersion !== 2) return false; + for (const key of SHARED_YIELD_TIMING_KEYS_V2) { + const category = timing[key]; + if (!category || !Number.isSafeInteger(category.identities) || category.identities < 0 + || category.identities !== sharedYieldCategoryIdentitiesV2(cumulative, key) + || (category.firstAtState !== null + && (!Number.isSafeInteger(category.firstAtState) || category.firstAtState < 0 || category.firstAtState > state)) + || (category.lastAtState !== null + && (!Number.isSafeInteger(category.lastAtState) || category.lastAtState < 0 || category.lastAtState > state)) + || (category.firstAtState !== null && category.lastAtState !== null + && category.firstAtState > category.lastAtState) + || (category.longestDryStates !== null + && (!Number.isSafeInteger(category.longestDryStates) + || category.longestDryStates < 0 || category.longestDryStates > state)) + || (category.currentDryStates !== null + && (!Number.isSafeInteger(category.currentDryStates) || category.currentDryStates < 0)) + || (category.lastAtState === null + ? category.currentDryStates !== null + : category.currentDryStates !== state - category.lastAtState) + || (category.longestDryStates !== null && category.currentDryStates !== null + && category.longestDryStates < category.currentDryStates) + || (category.identities === 0 + && (category.firstAtState !== null || category.lastAtState !== null + || category.currentDryStates !== null + || (eventHistoryComplete + ? category.longestDryStates !== 0 + : category.longestDryStates !== null))) + || (category.firstAtState !== null && category.lastAtState === null) + || (category.identitiesPerMillionTransitions !== timingRateV2(category.identities, state))) { + return false; + } + if (eventHistoryComplete) { + if (category.identities > 0 && (category.firstAtState === null || category.lastAtState === null + || category.longestDryStates === null)) { + return false; + } + } + } + return true; +} + function sharedCheckpointYieldCounts( state: SharedSearchCheckpoint["state"] ): SharedYieldCountsV1 { @@ -2109,7 +2801,24 @@ function validateSharedCheckpoint( fail("source, strategy, limits, seeds, state sensitivity, randomness, frontier envelope, or external bindings changed"); } const state = checkpoint.state; - if (!state || typeof state !== "object" || !Array.isArray(state.nodes)) fail("state and nodes are required"); + if (!state || typeof state !== "object") fail("state and nodes are required"); + const sharedObservability = state.sharedObservability; + if (sharedObservability?.schemaVersion === 2) { + if (!exactCanonicalRecord(sharedObservability, SHARED_OBSERVABILITY_CHECKPOINT_V2_KEYS) + || typeof sharedObservability.integritySha256 !== "string" + || !/^[0-9a-f]{64}$/.test(sharedObservability.integritySha256)) { + fail("shared observability v2 integrity checksum is missing or malformed"); + } + const expectedIntegrity = sharedObservabilityCheckpointV2IntegritySha256( + sharedObservability, + true + ); + if (!expectedIntegrity) fail("shared observability v2 canonical shape is malformed"); + if (expectedIntegrity !== sharedObservability.integritySha256) { + fail("shared observability v2 integrity checksum mismatch"); + } + } + if (!Array.isArray(state.nodes)) fail("state and nodes are required"); const arrays: Array<[unknown, string]> = [ [state.endings, "endings"], [state.visibleOutcomes, "visible outcomes"], [state.runtimeErrors, "runtime errors"], [state.runtimeWarnings, "runtime warnings"], @@ -2158,8 +2867,8 @@ function validateSharedCheckpoint( fail(`active node ${id} is missing serialized state`); } } - if (state.sharedObservability !== undefined) { - const observation = state.sharedObservability; + if (sharedObservability?.schemaVersion === 1) { + const observation = sharedObservability; const currentYield = sharedCheckpointYieldCounts(state); if (observation.schemaVersion !== 1 || !Number.isSafeInteger(observation.sampleIntervalStates) || observation.sampleIntervalStates < 1 @@ -2301,6 +3010,198 @@ function validateSharedCheckpoint( fail("shared observability sample cursor does not match its ledger"); } } + if (sharedObservability?.schemaVersion === 2) { + const observation = sharedObservability; + const currentYield = sharedCheckpointYieldCounts(state); + const interval = observation.sampleIntervalStates; + const expectedCadenceSamples = Number.isSafeInteger(interval) && interval > 0 + ? Math.floor(state.statesExplored / interval) - Math.floor(observation.baseState / interval) + : -1; + if (!Number.isSafeInteger(interval) || interval < 1 || interval > 10_000_000 + || !Number.isSafeInteger(observation.samplesRecorded) || observation.samplesRecorded < 0 + || !Number.isSafeInteger(observation.cadenceSamplesRecorded) || observation.cadenceSamplesRecorded < 0 + || observation.cadenceSamplesRecorded !== expectedCadenceSamples + || observation.samplesRecorded < observation.cadenceSamplesRecorded + || !Array.isArray(observation.samples) || observation.samples.length > MAX_SHARED_OBSERVABILITY_SAMPLES + || observation.samplesRecorded < observation.samples.length + || (observation.samplesRecorded === 0) !== (observation.samples.length === 0) + || observation.samplesRecorded > state.statesExplored - observation.baseState + || (observation.samplesRecorded <= MAX_SHARED_OBSERVABILITY_SAMPLES + && observation.samples.length !== observation.samplesRecorded) + || (observation.samples.length > 0 && observation.samples[0]?.sequence !== 1) + || !Number.isSafeInteger(observation.baseState) || observation.baseState < 0 + || observation.baseState > state.statesExplored + || !Number.isSafeInteger(observation.previousSampleState) + || observation.previousSampleState < observation.baseState + || observation.previousSampleState > state.statesExplored + || !Number.isSafeInteger(observation.nextSampleState) + || observation.nextSampleState !== (Math.floor(state.statesExplored / interval) + 1) * interval + || typeof observation.historyComplete !== "boolean" + || typeof observation.eventHistoryComplete !== "boolean" + || !validSharedYieldCounts(observation.baseYield) + || !validSharedYieldCounts(observation.previousYield) + || !validSharedYieldCounts(observation.throughFirstUseful) + || observation.baseYield.rawTerritory.transitions !== observation.baseState + || observation.previousYield.rawTerritory.transitions !== observation.previousSampleState + || !validSharedRetainedMemory(state.peakRetainedMemory) + || !sharedYieldCountsAtMost(observation.baseYield, observation.previousYield) + || !sharedYieldCountsAtMost(observation.previousYield, currentYield) + || !sharedYieldCountsAtMost(observation.throughFirstUseful, currentYield) + || (observation.historyComplete && observation.baseState !== 0) + || (observation.eventHistoryComplete && !observation.historyComplete) + || !validSharedYieldTimingV2( + observation.timing, + currentYield, + state.statesExplored, + observation.eventHistoryComplete + ) + || (observation.firstUsefulAtState !== null + && (!Number.isSafeInteger(observation.firstUsefulAtState) + || observation.firstUsefulAtState < 0 || observation.firstUsefulAtState > state.statesExplored)) + || (observation.firstCriticalAtState !== null + && (!Number.isSafeInteger(observation.firstCriticalAtState) + || observation.firstCriticalAtState < 0 || observation.firstCriticalAtState > state.statesExplored))) { + fail("shared observability v2 ledger is malformed"); + } + + const runtimeErrorStates = state.runtimeErrors.map(([, error]) => error?.firstDiscoveredAtState); + if (runtimeErrorStates.some((value) => !Number.isSafeInteger(value) + || value! < 0 || value! > state.statesExplored)) { + fail("runtime error discovery state is malformed"); + } + const expectedFirstCritical = runtimeErrorStates.length > 0 + ? Math.min(...runtimeErrorStates as number[]) + : null; + const currentUseful = usefulYieldObserved(currentYield); + const baseUseful = usefulYieldObserved(observation.baseYield); + const firstUseful = observation.firstUsefulAtState; + const firstCritical = observation.firstCriticalAtState; + const emptyYield = emptySharedYieldCounts(); + if (firstCritical !== expectedFirstCritical + || (firstCritical !== null && (firstUseful === null || firstUseful > firstCritical))) { + fail("shared observability v2 critical milestone is inconsistent"); + } + if (!currentUseful) { + if (firstUseful !== null || !sharedYieldCountsEqual(observation.throughFirstUseful, emptyYield)) { + fail("shared observability v2 useful milestone is inconsistent"); + } + } else if (firstUseful === null) { + fail("shared observability v2 useful milestone is missing"); + } else if (baseUseful) { + if (firstUseful > observation.baseState + || !sharedYieldCountsEqual(observation.throughFirstUseful, observation.baseYield)) { + fail("shared observability v2 baseline milestone is inconsistent"); + } + } else if (firstUseful <= observation.baseState + || !usefulYieldObserved(observation.throughFirstUseful) + || observation.throughFirstUseful.rawTerritory.transitions !== firstUseful) { + fail("shared observability v2 post-baseline milestone is inconsistent"); + } + + let previousState = observation.baseState; + let previousYield = observation.baseYield; + let previousSequence = 0; + let previousPeak: SharedRetainedMemory | undefined; + let previousReleasedNodes = 0; + let previousFrontierCompactions = 0; + for (const sample of observation.samples) { + const reasons = sample?.reasons; + const isCadence = Array.isArray(reasons) && reasons.includes("cadence"); + const delta = sample?.yield?.delta; + const yieldReasons = Array.isArray(reasons) + ? reasons.filter((reason) => { + const rank = SHARED_OBSERVABILITY_REASON_RANK_V2.get(reason as SharedObservabilityReasonV2); + return rank !== undefined && rank >= 1 && rank <= 8; + }) + : []; + const expectedYieldReasons = validSharedObservabilityTriggerYieldV2(sample?.triggerYield) + ? sharedObservabilityReasonsForTriggerYieldV2(sample.triggerYield) + : []; + const expectedTriggerMask = validSharedObservabilityReasonsV2(reasons) + ? sharedObservabilityTriggerMaskV2(reasons) + : 0; + const cadenceOrdinal = Number.isSafeInteger(sample?.state) && Number.isSafeInteger(interval) && interval > 0 + ? Math.floor(sample.state / interval) - Math.floor(observation.baseState / interval) + : -1; + const minimumSequence = cadenceOrdinal + (isCadence ? 0 : 1); + const recordsAfter = observation.samplesRecorded - (sample?.sequence ?? observation.samplesRecorded + 1); + const cadenceRecordsAfter = expectedCadenceSamples - cadenceOrdinal; + if (!sample || sample.schemaVersion !== 2 + || !Number.isSafeInteger(sample.sequence) || sample.sequence <= previousSequence + || sample.sequence > observation.samplesRecorded + || sample.sequence < minimumSequence + || sample.sequence > sample.state - observation.baseState + || recordsAfter < cadenceRecordsAfter + || recordsAfter > state.statesExplored - sample.state + || (observation.samplesRecorded <= MAX_SHARED_OBSERVABILITY_SAMPLES + && sample.sequence !== previousSequence + 1) + || !validSharedObservabilityReasonsV2(reasons) + || !Number.isSafeInteger(sample.triggerMask) || sample.triggerMask < 1 + || sample.triggerMask > SHARED_OBSERVABILITY_TRIGGER_MASK_MAX_V2 + || sample.triggerMask !== expectedTriggerMask + || !validSharedObservabilityTriggerYieldV2(sample.triggerYield) + || reasons.includes("termination") || reasons.includes("frontier_ceiling") + || reasons.includes("checkpoint") || reasons.includes("epoch") || reasons.includes("pressure") + || !Number.isSafeInteger(sample.state) || sample.state <= previousState + || sample.state > state.statesExplored + || (isCadence !== (sample.state % interval === 0)) + || !sample.retention || sample.retention.schemaVersion !== 1 + || !validSharedRetainedMemory(sample.retention.current) + || !validSharedRetainedMemory(sample.retention.peak) + || !sharedRetainedCurrentTotalIsExact(sample.retention.current) + || !sharedRetainedMemoryAtMost(sample.retention.current, sample.retention.peak) + || (previousPeak !== undefined && !sharedRetainedMemoryAtMost(previousPeak, sample.retention.peak)) + || !Number.isSafeInteger(sample.retention.releasedNodes) || sample.retention.releasedNodes < previousReleasedNodes + || !Number.isSafeInteger(sample.retention.frontierCompactions) + || sample.retention.frontierCompactions < previousFrontierCompactions + || (reasons.includes("frontier_compaction") + && sample.retention.frontierCompactions === previousFrontierCompactions) + || !sample.yield || sample.yield.schemaVersion !== 1 + || sample.yield.fromStateExclusive !== previousState + || sample.yield.throughState !== sample.state + || !validSharedYieldCounts(delta) + || !validSharedYieldCounts(sample.yield.cumulative) + || sample.yield.cumulative.rawTerritory.transitions !== sample.state + || !sharedYieldCountsAtMost(previousYield, sample.yield.cumulative) + || !sharedYieldCountsEqual(delta, subtractSharedYieldCounts(sample.yield.cumulative, previousYield)) + || (observation.eventHistoryComplete && sample.sequence === previousSequence + 1 + ? !sharedObservabilityTriggerYieldEqualV2(sample.triggerYield, delta) + : !sharedObservabilityTriggerYieldAtMostV2(sample.triggerYield, delta)) + || yieldReasons.length !== expectedYieldReasons.length + || yieldReasons.some((reason, index) => reason !== expectedYieldReasons[index])) { + fail("shared observability v2 samples are malformed or out of order"); + } + if (!baseUseful && firstUseful !== null) { + if (sample.state < firstUseful && usefulYieldObserved(sample.yield.cumulative)) { + fail("shared observability v2 useful milestone is later than retained useful yield"); + } + if (sample.state >= firstUseful + && !sharedYieldCountsAtMost(observation.throughFirstUseful, sample.yield.cumulative)) { + fail("shared observability v2 useful milestone exceeds retained cumulative yield"); + } + } + previousState = sample.state; + previousYield = sample.yield.cumulative; + previousSequence = sample.sequence; + previousPeak = sample.retention.peak; + previousReleasedNodes = sample.retention.releasedNodes; + previousFrontierCompactions = sample.retention.frontierCompactions; + } + if ((observation.samples.length > 0 && previousSequence !== observation.samplesRecorded) + || previousState !== observation.previousSampleState + || !sharedYieldCountsEqual(previousYield, observation.previousYield) + || (previousPeak !== undefined && !sharedRetainedMemoryAtMost(previousPeak, state.peakRetainedMemory)) + || previousReleasedNodes > state.releasedNodes + || previousFrontierCompactions > state.frontierCompactions) { + fail("shared observability v2 sample cursor does not match its ledger"); + } + } + if (sharedObservability !== undefined + && ![1, 2].includes((sharedObservability as { schemaVersion: number }).schemaVersion)) { + fail(`unsupported shared observability schema ${String( + (sharedObservability as { schemaVersion: number }).schemaVersion + )}`); + } const childCounts = new Array(state.nodes.length).fill(0); for (const node of state.nodes) { if (node?.parent !== null && node?.parent !== undefined) childCounts[node.parent]++; @@ -2556,8 +3457,56 @@ function createSharedEngine( }; const restoredObservability = restored?.sharedObservability; - let observabilitySamples = restoredObservability?.samples.map((sample) => cloneJsonValue(sample)) ?? []; - let observabilitySamplesRecorded = restoredObservability?.samplesRecorded ?? 0; + const restoredObservabilityV2: SharedObservabilityCheckpointV2Body | undefined = + restoredObservability?.schemaVersion === 2 + ? restoredObservability + : restoredObservability?.schemaVersion === 1 + ? { + schemaVersion: 2, + sampleIntervalStates: restoredObservability.sampleIntervalStates, + samplesRecorded: restoredObservability.samplesRecorded, + cadenceSamplesRecorded: restoredObservability.samplesRecorded, + samples: restoredObservability.samples.map((sample): ResourceSampleV2 => { + const reasons = canonicalSharedObservabilityReasonsV2([ + ...(sample.boundary === "interval" || sample.boundary === "interval_and_termination" + ? ["cadence" as const] : []), + ...(sample.boundary === "termination" || sample.boundary === "interval_and_termination" + ? ["termination" as const] : []), + ]); + return { + schemaVersion: 2, + sequence: Math.floor(sample.state / restoredObservability.sampleIntervalStates) + - Math.floor(restoredObservability.baseState / restoredObservability.sampleIntervalStates), + reasons, + triggerMask: sharedObservabilityTriggerMaskV2(reasons), + // V1 recorded only cadence boundaries. Zero means no V1 event + // trigger was recorded, not that no event occurred in the interval. + triggerYield: emptySharedObservabilityTriggerYieldV2(), + state: sample.state, + retention: cloneJsonValue(sample.retention), + yield: cloneJsonValue(sample.yield), + }; + }), + baseState: restoredObservability.baseState, + baseYield: cloneSharedYieldCounts(restoredObservability.baseYield), + previousSampleState: restoredObservability.previousSampleState, + previousYield: cloneSharedYieldCounts(restoredObservability.previousYield), + nextSampleState: restoredObservability.nextSampleState, + firstUsefulAtState: restoredObservability.firstUsefulAtState, + firstCriticalAtState: restoredObservability.firstCriticalAtState, + throughFirstUseful: cloneSharedYieldCounts(restoredObservability.throughFirstUseful), + historyComplete: restoredObservability.historyComplete, + eventHistoryComplete: false, + timing: migratedSharedYieldTimingV2( + sharedCheckpointYieldCounts(restored!), + restored?.statesExplored ?? 0 + ), + } + : undefined; + let observabilitySamples: ResourceSampleV2[] = restoredObservabilityV2?.samples + .map((sample) => cloneJsonValue(sample)) ?? []; + let observabilitySamplesRecorded = restoredObservabilityV2?.samplesRecorded ?? 0; + let observabilityCadenceSamplesRecorded = restoredObservabilityV2?.cadenceSamplesRecorded ?? 0; let observabilityBaseState = restoredObservability?.baseState ?? statesExplored; let observabilityBaseYield = restoredObservability ? cloneSharedYieldCounts(restoredObservability.baseYield) @@ -2574,6 +3523,10 @@ function createSharedEngine( ? cloneSharedYieldCounts(restoredObservability.throughFirstUseful) : emptySharedYieldCounts(); const observabilityHistoryComplete = restoredObservability?.historyComplete ?? !checkpoint; + const observabilityEventHistoryComplete = restoredObservabilityV2?.eventHistoryComplete ?? !checkpoint; + let observabilityTiming = restoredObservabilityV2 + ? cloneSharedYieldTimingV2(restoredObservabilityV2.timing) + : emptySharedYieldTimingV2(); const sharedYieldCounts = (): SharedYieldCountsV1 => ({ critical: { @@ -2606,6 +3559,31 @@ function createSharedEngine( } }; + let lastEventObservedYield = restored + ? cloneSharedYieldCounts(sharedCheckpointYieldCounts(restored!)) + : emptySharedYieldCounts(); + + const updateSharedYieldTiming = (current: SharedYieldCountsV1): void => { + for (const key of SHARED_YIELD_TIMING_KEYS_V2) { + const nextIdentities = sharedYieldCategoryIdentitiesV2(current, key); + const category = observabilityTiming[key]; + if (nextIdentities <= category.identities) continue; + if (category.identities === 0 && category.firstAtState === null) { + category.firstAtState = statesExplored; + } + if (category.lastAtState !== null && category.longestDryStates !== null) { + category.longestDryStates = Math.max( + category.longestDryStates, + statesExplored - category.lastAtState + ); + } + category.lastAtState = statesExplored; + category.identities = nextIdentities; + category.currentDryStates = 0; + category.identitiesPerMillionTransitions = timingRateV2(nextIdentities, statesExplored); + } + }; + const rebuildRetainedYieldIntervals = (): void => { let previousState = observabilityBaseState; let previousYield = observabilityBaseYield; @@ -2623,20 +3601,58 @@ function createSharedEngine( const last = observabilitySamples[observabilitySamples.length - 1]; const interior = observabilitySamples.slice(1, -1).filter((_, index) => index % 2 === 1); observabilitySamples = [first, ...interior, last]; + // Retained trigger masks and semantic tuples stay boundary-local; only the + // interval yield widens across records removed by deterministic compaction. rebuildRetainedYieldIntervals(); }; + let pendingLiveObservation: ResourceSampleV2 | undefined; + const emitPendingLiveObservation = (): void => { + if (!pendingLiveObservation) return; + const sample = pendingLiveObservation; + pendingLiveObservation = undefined; + opts.onSharedObservability?.({ + schemaVersion: 2, + pass: foundBy, + runWideState: sample.state, + sample: cloneJsonValue(sample), + process: observeProcessMemory(sample.retention.current.totalAccountedBytes), + }); + }; + const emitClosedLiveObservation = (): void => { + if (pendingLiveObservation && statesExplored > pendingLiveObservation.state) { + emitPendingLiveObservation(); + } + }; + const recordObservabilitySample = ( - boundary: ResourceSampleV1["boundary"] - ): ResourceSampleV1 => { - refreshFindingBytes(); + requestedReasons: Iterable, + triggerYield = emptySharedObservabilityTriggerYieldV2() + ): ResourceSampleV2 => { + const reasons = canonicalSharedObservabilityReasonsV2(requestedReasons); + if (reasons.length === 0) throw new Error("shared observability sample requires a reason"); + if (reasons.includes("cadence") || reasons.includes("termination")) refreshFindingBytes(); observeRetainedMemory(); const currentRetention = retainedMemory(); const cumulativeYield = sharedYieldCounts(); observeYieldMilestones(cumulativeYield); - const sample: ResourceSampleV1 = { - schemaVersion: 1, - boundary, + const latest = observabilitySamples.at(-1); + const sameState = latest?.state === statesExplored; + const priorYield = sameState + ? subtractSharedYieldCounts(latest.yield.cumulative, latest.yield.delta) + : previousObservabilityYield; + const sampleReasons = sameState + ? canonicalSharedObservabilityReasonsV2([...latest.reasons, ...reasons]) + : reasons; + const sampleTriggerYield = sameState + ? addSharedObservabilityTriggerYieldV2(latest.triggerYield, triggerYield) + : [...triggerYield] as SharedObservabilityTriggerYieldV2; + const sample: ResourceSampleV2 = { + schemaVersion: 2, + sequence: sameState ? latest.sequence : observabilitySamplesRecorded + 1, + reasons: sampleReasons, + triggerMask: sharedObservabilityTriggerMaskV2(sampleReasons), + triggerYield: sampleTriggerYield, state: statesExplored, retention: { schemaVersion: 1, @@ -2647,51 +3663,50 @@ function createSharedEngine( }, yield: { schemaVersion: 1, - fromStateExclusive: previousObservabilityState, + fromStateExclusive: sameState ? latest.yield.fromStateExclusive : previousObservabilityState, throughState: statesExplored, - delta: subtractSharedYieldCounts(cumulativeYield, previousObservabilityYield), + delta: subtractSharedYieldCounts(cumulativeYield, priorYield), cumulative: cloneSharedYieldCounts(cumulativeYield), }, }; - const latest = observabilitySamples.at(-1); - if (latest?.state === statesExplored && boundary === "termination") { - sample.boundary = latest.boundary === "interval" ? "interval_and_termination" : "termination"; - sample.yield.fromStateExclusive = latest.yield.fromStateExclusive; - sample.yield.delta = cloneSharedYieldCounts(latest.yield.delta); + if (sameState) { observabilitySamples[observabilitySamples.length - 1] = sample; } else { observabilitySamples.push(sample); observabilitySamplesRecorded++; - previousObservabilityState = statesExplored; - previousObservabilityYield = cloneSharedYieldCounts(cumulativeYield); compactObservabilitySamples(); } - opts.onSharedObservability?.({ - schemaVersion: 1, - pass: foundBy, - runWideState: statesExplored, - sample: cloneJsonValue(sample), - process: observeProcessMemory(currentRetention.totalAccountedBytes), - }); + previousObservabilityState = statesExplored; + previousObservabilityYield = cloneSharedYieldCounts(cumulativeYield); + if (reasons.includes("cadence") || pendingLiveObservation?.sequence === sample.sequence) { + pendingLiveObservation = sample; + } + if (reasons.includes("termination")) { + pendingLiveObservation = sample; + emitPendingLiveObservation(); + } return sample; }; const maybeRecordObservabilityInterval = (): void => { if (statesExplored < nextObservabilityState) return; - recordObservabilitySample("interval"); + observabilityCadenceSamplesRecorded++; + recordObservabilitySample(["cadence"]); nextObservabilityState = (Math.floor(statesExplored / observabilityIntervalStates) + 1) * observabilityIntervalStates; }; - const sharedObservabilityTelemetry = (): SharedObservabilityTelemetryV1 => { + const sharedObservabilityTelemetry = (): SharedObservabilityTelemetryV2 => { const cumulative = sharedYieldCounts(); return { - schemaVersion: 1, + schemaVersion: 2, sampleIntervalStates: observabilityIntervalStates, samplesRecorded: observabilitySamplesRecorded, + cadenceSamplesRecorded: observabilityCadenceSamplesRecorded, samplesRetained: observabilitySamples.length, samplesCompacted: Math.max(0, observabilitySamplesRecorded - observabilitySamples.length), historyComplete: observabilityHistoryComplete, + eventHistoryComplete: observabilityEventHistoryComplete, samples: observabilitySamples.map((sample) => cloneJsonValue(sample)), yieldSummary: { schemaVersion: 1, @@ -2703,6 +3718,7 @@ function createSharedEngine( : subtractSharedYieldCounts(cumulative, throughFirstUseful), cumulative: cloneSharedYieldCounts(cumulative), }, + timing: snapshotSharedYieldTimingV2(observabilityTiming, statesExplored), }; }; @@ -2717,12 +3733,20 @@ function createSharedEngine( stagesReached: goals.reachedStageCount(), uniqueStatesObserved: seenStates.size, }); - observeYieldMilestones(); + const currentYield = sharedYieldCounts(); + observeYieldMilestones(currentYield); if (observed) { lastDiscoveryAtState = statesExplored; refreshFindingBytes(); observeRetainedMemory(); } + const eventDelta = subtractSharedYieldCounts(currentYield, lastEventObservedYield); + const reasons = sharedObservabilityReasonsForYieldDeltaV2(eventDelta); + updateSharedYieldTiming(currentYield); + lastEventObservedYield = cloneSharedYieldCounts(currentYield); + if (reasons.length > 0 && statesExplored > observabilityBaseState) { + recordObservabilitySample(reasons, sharedObservabilityTriggerYieldV2(eventDelta)); + } }; const recordKnotCoverage = (session: StorySession): number => { @@ -2802,7 +3826,10 @@ function createSharedEngine( novelty.compact(valid); variablePriority.compact(valid); goalPriority.compact(valid); - if (frontierReferenceCount() < references) frontierCompactions++; + if (frontierReferenceCount() < references) { + frontierCompactions++; + recordObservabilitySample(["frontier_compaction"]); + } observeRetainedMemory(); }; @@ -2826,6 +3853,7 @@ function createSharedEngine( frontierStopped = true; truncated = true; truncatedBy.frontier = true; + recordObservabilitySample(["frontier_ceiling"]); return false; } const id = nodes.length; @@ -2962,6 +3990,8 @@ function createSharedEngine( const baseline = sharedYieldCounts(); observabilityBaseYield = cloneSharedYieldCounts(baseline); previousObservabilityYield = cloneSharedYieldCounts(baseline); + lastEventObservedYield = cloneSharedYieldCounts(baseline); + observabilityTiming = migratedSharedYieldTimingV2(baseline, statesExplored); const priorFirst = discoveryCurve.summary(statesExplored).firstDiscoveryAtState; if (usefulYieldObserved(baseline)) { // Older schema-v1 checkpoints may prove that useful evidence already @@ -3259,6 +4289,34 @@ function createSharedEngine( if (done() || memoryStopped || timeStopped) { throw new RangeError("Shared search cannot checkpoint after completion or a resource stop"); } + const sharedObservabilityBody: SharedObservabilityCheckpointV2Body = { + schemaVersion: 2, + sampleIntervalStates: observabilityIntervalStates, + samplesRecorded: observabilitySamplesRecorded, + cadenceSamplesRecorded: observabilityCadenceSamplesRecorded, + samples: observabilitySamples.map((sample) => cloneJsonValue(sample)), + baseState: observabilityBaseState, + baseYield: cloneSharedYieldCounts(observabilityBaseYield), + previousSampleState: previousObservabilityState, + previousYield: cloneSharedYieldCounts(previousObservabilityYield), + nextSampleState: nextObservabilityState, + firstUsefulAtState, + firstCriticalAtState, + throughFirstUseful: cloneSharedYieldCounts(throughFirstUseful), + historyComplete: observabilityHistoryComplete, + eventHistoryComplete: observabilityEventHistoryComplete, + timing: snapshotSharedYieldTimingV2(observabilityTiming, statesExplored), + }; + const orderedSharedObservabilityBody = orderedSharedObservabilityCheckpointV2Body( + sharedObservabilityBody, + false + ); + const integritySha256 = orderedSharedObservabilityBody + ? sharedObservabilityCheckpointV2IntegritySha256(orderedSharedObservabilityBody, false) + : undefined; + if (!orderedSharedObservabilityBody || !integritySha256) { + throw new Error("Shared observability v2 checkpoint could not be canonicalized"); + } const value: SharedSearchCheckpoint = { schemaVersion: SHARED_SEARCH_CHECKPOINT_SCHEMA_VERSION, engine: "shared:deep-novelty-v1", @@ -3308,21 +4366,7 @@ function createSharedEngine( findingBytes, ancestryPayloadBytes, peakRetainedMemory: { ...peakRetainedMemory }, - sharedObservability: { - schemaVersion: 1, - sampleIntervalStates: observabilityIntervalStates, - samplesRecorded: observabilitySamplesRecorded, - samples: observabilitySamples.map((sample) => cloneJsonValue(sample)), - baseState: observabilityBaseState, - baseYield: cloneSharedYieldCounts(observabilityBaseYield), - previousSampleState: previousObservabilityState, - previousYield: cloneSharedYieldCounts(previousObservabilityYield), - nextSampleState: nextObservabilityState, - firstUsefulAtState, - firstCriticalAtState, - throughFirstUseful: cloneSharedYieldCounts(throughFirstUseful), - historyComplete: observabilityHistoryComplete, - }, + sharedObservability: { ...orderedSharedObservabilityBody, integritySha256 }, }, }; return cloneJsonValue(value); @@ -3347,7 +4391,13 @@ function createSharedEngine( break; } } - if (!advance()) break; + const advanced = advance(); + // A successful transition closes the preceding pass-local state. Emit + // its pending cadence observation only after the new transition is + // fully integrated, so callback failure cannot strand a half-applied + // story step. A no-work advance leaves it pending for termination. + emitClosedLiveObservation(); + if (!advanced) break; // Compaction timing is part of frontier order. Tie it to cumulative // work, not caller chunk boundaries, so pause/resume cannot perturb it. if (statesExplored > 0 && statesExplored % 1_000 === 0) compactFrontiers(); @@ -3373,7 +4423,7 @@ function createSharedEngine( truncatedBy.maxStates = true; } compactFrontiers(true); - recordObservabilitySample("termination"); + recordObservabilitySample(["termination"]); return buildResult(); }, telemetry(): PassTelemetry { diff --git a/src/machine-output.ts b/src/machine-output.ts index 00e798c..743f79e 100644 --- a/src/machine-output.ts +++ b/src/machine-output.ts @@ -1,3 +1,5 @@ +import { SHARED_OBSERVABILITY_REASON_ORDER_V2 } from "./explore"; + export type MachineDetail = "summary" | "standard" | "full"; export const DEFAULT_MACHINE_DETAIL: MachineDetail = "standard"; @@ -169,6 +171,8 @@ function executionSummary(value: unknown) { const MAX_COMPACT_SHARED_OBSERVABILITY_PASSES = 8; const MAX_SHARED_OBSERVABILITY_SAMPLES = 128; +const MAX_SHARED_OBSERVABILITY_TRIGGER_MASK_V2 = + (1 << SHARED_OBSERVABILITY_REASON_ORDER_V2.length) - 1; const SHARED_RETAINED_MEMORY_FIELDS = [ "pendingStateBytes", "pendingVariableBytes", "activeStateBytes", "activeVariableBytes", "ancestryBytes", "dedupeBytes", "semanticIndexBytes", "frontierReferenceBytes", @@ -235,6 +239,42 @@ function sharedYieldCounts(value: unknown): Record | undefined }; } +function sharedObservabilityYieldVector(value: Record): number[] | undefined { + const critical = record(value.critical); + const intent = record(value.intent); + const authoredCoverage = record(value.authoredCoverage); + const counts = [ + critical?.runtimeErrors, + critical?.assertionViolations, + intent?.goalsReached, + intent?.stagesReached, + authoredCoverage?.knotsVisited, + value.visibleOutcomes, + value.semanticTransitions, + value.terminalVariants, + ]; + return counts.every(nonNegativeSafeInteger) ? counts as number[] : undefined; +} + +function sharedObservabilityTriggerYield( + value: unknown, + intervalDelta?: Record +): number[] | undefined { + if (!Array.isArray(value) || value.length !== 8) return undefined; + const projected: number[] = []; + for (const count of value) { + if (!nonNegativeSafeInteger(count)) return undefined; + projected.push(count); + } + if (intervalDelta) { + const intervalVector = sharedObservabilityYieldVector(intervalDelta); + if (!intervalVector || projected.some((count, index) => count > intervalVector[index])) { + return undefined; + } + } + return projected; +} + function sharedResourceSample(value: unknown): Record | undefined { const sample = record(value); const retention = record(sample?.retention); @@ -243,9 +283,38 @@ function sharedResourceSample(value: unknown): Record | undefin const peak = sharedRetainedMemory(retention?.peak); const delta = sharedYieldCounts(interval?.delta); const cumulative = sharedYieldCounts(interval?.cumulative); - if (!sample || sample.schemaVersion !== 1 - || typeof sample.boundary !== "string" - || !["interval", "termination", "interval_and_termination"].includes(sample.boundary) + const v1 = sample?.schemaVersion === 1; + const v2 = sample?.schemaVersion === 2; + const reasons = v2 ? array(sample?.reasons) : []; + const reasonRanks = reasons.length <= SHARED_OBSERVABILITY_REASON_ORDER_V2.length + ? reasons.map((reason) => + SHARED_OBSERVABILITY_REASON_ORDER_V2.indexOf( + reason as typeof SHARED_OBSERVABILITY_REASON_ORDER_V2[number] + )) + : []; + const expectedTriggerMask = reasonRanks.reduce( + (mask, rank) => rank < 0 ? mask : mask | (1 << rank), + 0 + ); + const triggerYield = v2 + ? sharedObservabilityTriggerYield(sample?.triggerYield, delta) + : undefined; + const yieldReasonPositions = new Set( + reasonRanks.filter((rank) => rank >= 1 && rank <= 8).map((rank) => rank - 1) + ); + const triggerYieldMatchesReasons = triggerYield?.every( + (count, index) => (count > 0) === yieldReasonPositions.has(index) + ) === true; + if (!sample || (!v1 && !v2) + || (v1 && (typeof sample.boundary !== "string" + || !["interval", "termination", "interval_and_termination"].includes(sample.boundary as string))) + || (v2 && (!nonNegativeSafeInteger(sample.sequence) || sample.sequence < 1 + || reasons.length < 1 || reasons.length > SHARED_OBSERVABILITY_REASON_ORDER_V2.length + || reasonRanks.some((rank, index) => rank < 0 || (index > 0 && rank <= reasonRanks[index - 1])) + || !nonNegativeSafeInteger(sample.triggerMask) || sample.triggerMask < 1 + || sample.triggerMask > MAX_SHARED_OBSERVABILITY_TRIGGER_MASK_V2 + || sample.triggerMask !== expectedTriggerMask + || !triggerYield || !triggerYieldMatchesReasons)) || !nonNegativeSafeInteger(sample.state) || !retention || retention.schemaVersion !== 1 || !current || !peak || !nonNegativeSafeInteger(retention.releasedNodes) @@ -259,8 +328,15 @@ function sharedResourceSample(value: unknown): Record | undefin return undefined; } return { - schemaVersion: 1, - boundary: sample.boundary, + schemaVersion: sample.schemaVersion, + ...(v1 + ? { boundary: sample.boundary } + : { + sequence: sample.sequence, + reasons, + triggerMask: sample.triggerMask, + triggerYield, + }), state: sample.state, retention: { schemaVersion: 1, @@ -279,6 +355,39 @@ function sharedResourceSample(value: unknown): Record | undefin }; } +function sharedYieldTimingCategory(value: unknown): Record | undefined { + const category = record(value); + if (!category || !nonNegativeSafeInteger(category.identities)) return undefined; + for (const field of ["firstAtState", "lastAtState", "longestDryStates", "currentDryStates"]) { + const item = category[field]; + if (item !== null && !nonNegativeSafeInteger(item)) return undefined; + } + const rate = category.identitiesPerMillionTransitions; + if (rate !== null && (typeof rate !== "number" || !Number.isFinite(rate) || rate < 0)) return undefined; + return { + identities: category.identities, + firstAtState: category.firstAtState, + lastAtState: category.lastAtState, + longestDryStates: category.longestDryStates, + currentDryStates: category.currentDryStates, + identitiesPerMillionTransitions: rate, + }; +} + +function sharedYieldTiming(value: unknown): Record | undefined { + const timing = record(value); + if (!timing || timing.schemaVersion !== 2) return undefined; + const projected: Record = { schemaVersion: 2 }; + for (const field of [ + "critical", "intent", "authoredCoverage", "visibleOutcomes", "semanticTransitions", "terminalVariants", + ]) { + const category = sharedYieldTimingCategory(timing[field]); + if (!category) return undefined; + projected[field] = category; + } + return projected; +} + function sharedYieldSummary(value: unknown): Record | undefined { const summary = record(value); const throughFirstUseful = sharedYieldCounts(summary?.throughFirstUseful); @@ -309,7 +418,9 @@ function sharedObservabilitySummary(value: unknown) { const pass = record(passValue); const telemetry = record(pass?.sharedObservability); const projectedPass = sharedPass(pass?.pass); - if (!pass || !telemetry || !projectedPass || telemetry.schemaVersion !== 1 + const v1 = telemetry?.schemaVersion === 1; + const v2 = telemetry?.schemaVersion === 2; + if (!pass || !telemetry || !projectedPass || (!v1 && !v2) || !nonNegativeSafeInteger(telemetry.sampleIntervalStates) || telemetry.sampleIntervalStates < 1 || telemetry.sampleIntervalStates > 10_000_000 || !nonNegativeSafeInteger(telemetry.samplesRecorded) @@ -317,22 +428,29 @@ function sharedObservabilitySummary(value: unknown) { || telemetry.samplesRetained > MAX_SHARED_OBSERVABILITY_SAMPLES || !nonNegativeSafeInteger(telemetry.samplesCompacted) || telemetry.samplesRecorded - telemetry.samplesRetained !== telemetry.samplesCompacted - || typeof telemetry.historyComplete !== "boolean") continue; + || typeof telemetry.historyComplete !== "boolean" + || (v2 && (!nonNegativeSafeInteger(telemetry.cadenceSamplesRecorded) + || telemetry.cadenceSamplesRecorded > telemetry.samplesRecorded + || typeof telemetry.eventHistoryComplete !== "boolean"))) continue; const samples = array(telemetry.samples); if (samples.length !== telemetry.samplesRetained) continue; const latest = samples.length ? sharedResourceSample(samples.at(-1)) : undefined; const yieldSummary = sharedYieldSummary(telemetry.yieldSummary); - if ((samples.length && !latest) || !yieldSummary) continue; + const timing = v2 ? sharedYieldTiming(telemetry.timing) : undefined; + if ((samples.length && !latest) || !yieldSummary || (v2 && !timing)) continue; summaries.push({ pass: projectedPass, - schemaVersion: 1, + schemaVersion: telemetry.schemaVersion, sampleIntervalStates: telemetry.sampleIntervalStates, samplesRecorded: telemetry.samplesRecorded, + ...(v2 ? { cadenceSamplesRecorded: telemetry.cadenceSamplesRecorded } : {}), samplesRetained: telemetry.samplesRetained, samplesCompacted: telemetry.samplesCompacted, historyComplete: telemetry.historyComplete, + ...(v2 ? { eventHistoryComplete: telemetry.eventHistoryComplete } : {}), ...(latest ? { latestSample: latest } : {}), yieldSummary, + ...(timing ? { timing } : {}), }); } return summaries.length ? summaries : undefined; diff --git a/src/terminal-progress.ts b/src/terminal-progress.ts index a41d4d2..0c01654 100644 --- a/src/terminal-progress.ts +++ b/src/terminal-progress.ts @@ -1,4 +1,4 @@ -import type { SharedResourceObservationV1 } from "./explore"; +import type { SharedResourceObservation } from "./explore"; export type HumanProgressPhase = "compile" | "source_scan" | "explore" | "min_repro" | "report"; export type ProgressStatus = "complete" | "cancelled" | "error"; @@ -44,7 +44,7 @@ export interface HumanProgressEvent { outcome?: ProgressOutcome; exhaustive?: boolean; /** Shared-search only; contains counts/bytes but no story content or identities. */ - sharedObservability?: SharedResourceObservationV1; + sharedObservability?: SharedResourceObservation; } export interface TerminalWriter { diff --git a/test/inkcheck.test.js b/test/inkcheck.test.js index 527d39e..dcaed74 100644 --- a/test/inkcheck.test.js +++ b/test/inkcheck.test.js @@ -613,17 +613,33 @@ test("additive goal resource observations separate run-wide progress from pass-l index === 0 || observation.runWideState >= observations[index - 1].runWideState )), baseline); const directed = observations.filter((observation) => observation.pass.startsWith("shared:goal-directed")); - assert.deepStrictEqual(directed.map((observation) => observation.sample.state), [25, 50, 50], baseline); - assert.deepStrictEqual(directed.map((observation) => observation.runWideState), [125, 150, 150], baseline); + assert.strictEqual(directed.length, 2, `${baseline}: live output stays cadence/termination bounded`); + assert.ok(directed.every((observation, index) => ( + observation.schemaVersion === 2 + && observation.sample.schemaVersion === 2 + && observation.runWideState === 100 + observation.sample.state + && (index === 0 || observation.sample.state >= directed[index - 1].sample.state) + )), baseline); + assert.deepStrictEqual( + [...new Set(directed + .filter((observation) => observation.sample.reasons.includes("cadence")) + .map((observation) => observation.sample.state))], + [25, 50], + baseline + ); + assert.strictEqual(directed.at(-1).sample.state, 50, baseline); + assert.ok(directed.at(-1).sample.reasons.includes("termination"), baseline); if (baseline === "shared") { + const general = observations.filter((observation) => !observation.pass.startsWith("shared:goal-directed")); + assert.ok(general.every((observation) => observation.runWideState === observation.sample.state)); assert.deepStrictEqual( - observations.map((observation) => observation.sample.state), - [25, 50, 75, 100, 100, 25, 50, 50] - ); - assert.deepStrictEqual( - observations.map((observation) => observation.runWideState), - [25, 50, 75, 100, 100, 125, 150, 150] + [...new Set(general + .filter((observation) => observation.sample.reasons.includes("cadence")) + .map((observation) => observation.sample.state))], + [25, 50, 75, 100] ); + assert.strictEqual(general.at(-1).sample.state, 100); + assert.ok(general.at(-1).sample.reasons.includes("termination")); } } }); @@ -4557,13 +4573,17 @@ test("shared CLI progress emits bounded resource observations without changing c const report = JSON.parse(streamed.stdout); const events = streamed.stderr.trim().split("\n").map((line) => JSON.parse(line)); const resources = events.filter((event) => event.type === "resource"); - assert.strictEqual(resources.length, 1); + assert.ok(resources.length >= 1); const observation = resources[0].sharedObservability; - assert.strictEqual(observation.schemaVersion, 1); + assert.strictEqual(observation.schemaVersion, 2); assert.match(observation.pass, /^shared:/); - assert.strictEqual(observation.sample.boundary, "termination"); - assert.strictEqual(observation.sample.state, report.explore.statesExplored); - assert.strictEqual(observation.runWideState, report.explore.statesExplored); + const terminalObservation = resources.at(-1).sharedObservability; + assert.ok(terminalObservation.sample.reasons.includes("termination")); + assert.strictEqual(terminalObservation.sample.state, report.explore.statesExplored); + assert.strictEqual(terminalObservation.runWideState, report.explore.statesExplored); + assert.ok(resources.some((event) => event.sharedObservability.sample.reasons.some( + (reason) => reason !== "cadence" && reason !== "termination" + ))); assert.ok(observation.sample.retention.current.totalAccountedBytes > 0); assert.strictEqual(observation.process.scope, "process"); assert.ok(observation.process.heapUsedBytes > 0); @@ -4572,7 +4592,7 @@ test("shared CLI progress emits bounded resource observations without changing c observation.process.comparedLogicalAccountedBytes, observation.sample.retention.current.totalAccountedBytes ); - assert.deepStrictEqual(events.at(-1).sharedObservability, observation); + assert.deepStrictEqual(events.at(-1).sharedObservability, terminalObservation); assert.doesNotMatch(JSON.stringify(resources), /path_code|wide tree leaf|"Left"|"Center"|"Right"/i); assert.doesNotMatch(streamed.stdout, /heapUsedBytes|heapTotalBytes|rssBytes|unattributedBytes/); }); @@ -4894,6 +4914,8 @@ test("NDJSON progress contract docs stay linked and privacy-focused", () => { const readme = fs.readFileSync(path.join(ROOT, "README.md"), "utf8"); const docs = fs.readFileSync(path.join(ROOT, "docs", "progress-ndjson.md"), "utf8"); assert.match(readme, /docs\/progress-ndjson\.md/); + assert.ok(JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")) + .files.includes("docs/progress-ndjson.md")); assert.match(docs, /schemaVersion: 1/); assert.match(docs, /stdout report as authoritative/); assert.match(docs, /work-budget progress, not story coverage/); @@ -4911,17 +4933,18 @@ test("shared observability contract is linked, packaged, and explicit about its assert.match(readme, /docs\/shared-search-observability\.md/); assert.ok(packageJson.files.includes("docs/shared-search-observability.md")); assert.match(docs, /issue #216/); - assert.match(docs, /which remains open/); - assert.match(docs, /not the complete long-run resource policy/); - assert.match(docs, /only sampling boundaries in this slice/); - assert.match(docs, /Checkpoint save\/resume, discovery events, memory or frontier pressure/); + assert.match(docs, /Issue #216 remains open/); + assert.match(docs, /not a resource, allocation, or stopping policy/); + assert.match(docs, /This slice emits cadence, category, `frontier_compaction`, `frontier_ceiling`, and `termination` reasons/); + assert.match(docs, /checkpoint save\/reopen, epoch lifecycle, memory\/time pressure[\s\S]*do not emit those boundaries yet/); assert.match(docs, /never produces a weighted usefulness score/); - assert.match(docs, /not complete owner attribution/); - assert.match(docs, /rediscovery identities, throughput, retained GiB-minutes/); + assert.match(docs, /Owner attribution remains incomplete/); + assert.match(docs, /does not add kth-event milestones, campaign-new versus rediscovered identities[\s\S]*retained-GiB-minute rates/); assert.match(docs, /excluded from shared checkpoints, canonical JSON reports/); assert.match(docs, /runWideState/); - assert.match(docs, /nested pass-local sample/); - assert.match(docs, /historyComplete: false/); + assert.match(docs, /never rewrites the nested pass-local sample/); + assert.match(docs, /eventHistoryComplete: false/); + assert.match(docs, /not an overhead claim for this exact V2 implementation head/); }); test("Rules That Matter contract stays linked, packaged, and bounded", () => { @@ -4987,6 +5010,28 @@ test("human progress uses work-budget language and stays readable without termin stagesReached: 0, }, }); + renderer.handle({ + type: "resource", + elapsedMs: 12_150, + statesExplored: 37_250, + stateBudget: 100_000, + sharedObservability: { + schemaVersion: 2, + pass: "shared:deep-novelty-v1:seed=7", + runWideState: 37_250, + sample: { + schemaVersion: 2, + sequence: 4, + reasons: ["cadence", "authored_knot"], + state: 37_250, + retention: { current: { totalAccountedBytes: 8 * 1048576 } }, + }, + process: { + heapUsedBytes: 64 * 1048576, + rssBytes: 96 * 1048576, + }, + }, + }); renderer.handle({ type: "run_end", status: "complete", @@ -5002,6 +5047,7 @@ test("human progress uses work-budget language and stays readable without termin assert.doesNotMatch(output, /coverage/); assert.doesNotMatch(output, /\x1b\[/); assert.match(output, /Found \+1 error, \+1 ending, \+2 knots/); + assert.match(output, /Shared search:[\s\S]*logical 8\.0 MiB/); assert.match(output, /Finished: state budget reached; results are partial/); }); diff --git a/test/machine-output.test.js b/test/machine-output.test.js index cb84cce..fb5d6b4 100644 --- a/test/machine-output.test.js +++ b/test/machine-output.test.js @@ -11,6 +11,18 @@ const { machineFindingSummaries, projectMachineReport, } = require("../dist/machine-output"); +const { SHARED_OBSERVABILITY_REASON_ORDER_V2 } = require("../dist/explore"); + +const MAX_SHARED_OBSERVABILITY_TRIGGER_MASK_V2 = + (1 << SHARED_OBSERVABILITY_REASON_ORDER_V2.length) - 1; + +function sharedObservabilityTriggerMask(reasons) { + return reasons.reduce((mask, reason) => { + const rank = SHARED_OBSERVABILITY_REASON_ORDER_V2.indexOf(reason); + assert.notStrictEqual(rank, -1, `unknown shared-observability reason ${reason}`); + return mask | (1 << rank); + }, 0); +} function finding(kind, index, extra = {}) { return { @@ -171,6 +183,50 @@ function validSharedObservability() { }; } +function sharedTimingCategory(identities, firstAtState, lastAtState, state) { + return { + identities, + firstAtState, + lastAtState, + longestDryStates: lastAtState === null ? 0 : state - lastAtState, + currentDryStates: lastAtState === null ? null : state - lastAtState, + identitiesPerMillionTransitions: identities * 1_000_000 / state, + }; +} + +function validSharedObservabilityV2() { + const telemetry = validSharedObservability(); + return { + ...telemetry, + schemaVersion: 2, + cadenceSamplesRecorded: 1, + eventHistoryComplete: true, + samples: telemetry.samples.map((sample, index) => ({ + schemaVersion: 2, + sequence: index + 1, + reasons: index === 0 ? ["cadence"] : ["terminal_variant", "termination"], + triggerMask: sharedObservabilityTriggerMask( + index === 0 ? ["cadence"] : ["terminal_variant", "termination"] + ), + triggerYield: index === 0 + ? [0, 0, 0, 0, 0, 0, 0, 0] + : [0, 0, 0, 0, 0, 0, 0, 1], + state: sample.state, + retention: sample.retention, + yield: sample.yield, + })), + timing: { + schemaVersion: 2, + critical: sharedTimingCategory(2, 4, 4, 12_000), + intent: sharedTimingCategory(4, 4, 4, 12_000), + authoredCoverage: sharedTimingCategory(2, 4, 4, 12_000), + visibleOutcomes: sharedTimingCategory(2, 4, 4, 12_000), + semanticTransitions: sharedTimingCategory(2, 4, 4, 12_000), + terminalVariants: sharedTimingCategory(2, 4, 12_000, 12_000), + }, + }; +} + test("default machine detail stays bounded and keeps response truncation separate from search truncation", () => { const report = largeReport(); const projected = projectMachineReport(report); @@ -266,6 +322,148 @@ test("compact shared observability recursively whitelists its latest sample and assert.ok(Buffer.byteLength(serialized, "utf8") <= MAX_STANDARD_MACHINE_RESPONSE_BYTES); }); +test("compact shared observability V2 whitelists reasons and timing without forwarding future fields", () => { + const report = largeReport(); + const telemetry = validSharedObservabilityV2(); + const secret = "PRIVATE V2 OBSERVABILITY SECRET ".repeat(10_000); + report.explore.passes[0].pass = "shared:deep-novelty-v1:seed=7"; + report.explore.passes[0].sharedObservability = telemetry; + telemetry.samples.at(-1).privateReasonIdentity = secret; + telemetry.samples.at(-1).privateTriggerIdentity = secret; + telemetry.samples.at(-1).privateTriggerYieldIdentity = secret; + telemetry.timing.privateCategory = { secret }; + telemetry.timing.critical.privateEvent = secret; + const projected = projectMachineReport(report, "summary"); + const expectedLatest = structuredClone(telemetry.samples.at(-1)); + delete expectedLatest.privateReasonIdentity; + delete expectedLatest.privateTriggerIdentity; + delete expectedLatest.privateTriggerYieldIdentity; + assert.deepStrictEqual(projected.explore.sharedObservability, [{ + pass: "shared:deep-novelty-v1:seed=7", + schemaVersion: 2, + sampleIntervalStates: 10_000, + samplesRecorded: 2, + cadenceSamplesRecorded: 1, + samplesRetained: 2, + samplesCompacted: 0, + historyComplete: true, + eventHistoryComplete: true, + latestSample: expectedLatest, + yieldSummary: structuredClone(telemetry.yieldSummary), + timing: { + schemaVersion: 2, + critical: sharedTimingCategory(2, 4, 4, 12_000), + intent: sharedTimingCategory(4, 4, 4, 12_000), + authoredCoverage: sharedTimingCategory(2, 4, 4, 12_000), + visibleOutcomes: sharedTimingCategory(2, 4, 4, 12_000), + semanticTransitions: sharedTimingCategory(2, 4, 4, 12_000), + terminalVariants: sharedTimingCategory(2, 4, 12_000, 12_000), + }, + }]); + const serialized = JSON.stringify(projected); + assert.doesNotMatch(serialized, /PRIVATE V2 OBSERVABILITY SECRET|privateCategory|privateEvent/); + assert.strictEqual( + projected.explore.sharedObservability[0].latestSample.triggerMask, + (1 << 8) | (1 << 14) + ); + assert.deepStrictEqual( + projected.explore.sharedObservability[0].latestSample.triggerYield, + [0, 0, 0, 0, 0, 0, 0, 1] + ); + assert.ok(Buffer.byteLength(serialized, "utf8") <= MAX_STANDARD_MACHINE_RESPONSE_BYTES); +}); + +test("compact shared observability requires exact bounded V2 trigger yield", () => { + const secret = "PRIVATE OVERSIZED TRIGGER YIELD ".repeat(10_000); + const mutations = [ + ["wrong tuple length", (sample) => { sample.triggerYield = [0, 0, 0, 0, 0, 0, 1]; }], + ["fractional tuple count", (sample) => { sample.triggerYield[7] = 0.5; }], + ["negative tuple count", (sample) => { sample.triggerYield[7] = -1; }], + ["tuple exceeds interval delta", (sample) => { sample.triggerYield[7] = 2; }], + ["coordinated reason/mask addition without tuple evidence", (sample) => { + sample.reasons = ["runtime_error", "terminal_variant", "termination"]; + sample.triggerMask = sharedObservabilityTriggerMask(sample.reasons); + }], + ["coordinated reason/mask removal with stale tuple evidence", (sample) => { + sample.reasons = ["termination"]; + sample.triggerMask = sharedObservabilityTriggerMask(sample.reasons); + }], + ["oversized trigger-yield tuple", (sample) => { + sample.triggerYield = Array.from({ length: 10_000 }, () => secret); + }], + ]; + for (const [label, mutate] of mutations) { + const report = largeReport(); + const telemetry = validSharedObservabilityV2(); + mutate(telemetry.samples.at(-1)); + report.explore.passes = [{ + pass: "shared:deep-novelty-v1:seed=7", + sharedObservability: telemetry, + }]; + const projected = projectMachineReport(report, "summary"); + assert.strictEqual(projected.explore.sharedObservability, undefined, label); + assert.doesNotMatch(JSON.stringify(projected), /PRIVATE OVERSIZED TRIGGER YIELD/, label); + } +}); + +test("compact shared observability requires an exact bounded V2 trigger mask", () => { + assert.strictEqual(SHARED_OBSERVABILITY_REASON_ORDER_V2.length, 15); + assert.strictEqual(sharedObservabilityTriggerMask(["cadence"]), 1); + assert.strictEqual(sharedObservabilityTriggerMask(["termination"]), 1 << 14); + assert.strictEqual( + sharedObservabilityTriggerMask(SHARED_OBSERVABILITY_REASON_ORDER_V2), + MAX_SHARED_OBSERVABILITY_TRIGGER_MASK_V2 + ); + + const secret = "PRIVATE OVERSIZED TRIGGER VECTOR ".repeat(10_000); + const mutations = [ + ["zero mask", (sample) => { sample.triggerMask = 0; }], + ["unknown high bit", (sample) => { sample.triggerMask = 1 << 15; }], + ["non-integer mask", (sample) => { sample.triggerMask = 1.5; }], + ["mask/reason mismatch", (sample) => { sample.triggerMask ^= 1 << 8; }], + ["reason/mask mismatch", (sample) => { sample.reasons = ["termination"]; }], + ["oversized reason vector", (sample) => { + sample.reasons = Array.from({ length: 10_000 }, () => secret); + }], + ]; + for (const [label, mutate] of mutations) { + const report = largeReport(); + const telemetry = validSharedObservabilityV2(); + mutate(telemetry.samples.at(-1)); + report.explore.passes = [{ + pass: "shared:deep-novelty-v1:seed=7", + sharedObservability: telemetry, + }]; + const projected = projectMachineReport(report, "summary"); + assert.strictEqual(projected.explore.sharedObservability, undefined, label); + assert.doesNotMatch(JSON.stringify(projected), /PRIVATE OVERSIZED TRIGGER VECTOR/, label); + } +}); + +test("compact shared observability preserves nullable migrated V2 timing without inventing history", () => { + const report = largeReport(); + const telemetry = validSharedObservabilityV2(); + telemetry.eventHistoryComplete = false; + for (const category of Object.values(telemetry.timing).filter((value) => typeof value === "object")) { + category.firstAtState = null; + category.lastAtState = null; + category.longestDryStates = null; + category.currentDryStates = null; + } + report.explore.passes[0].pass = "shared:deep-novelty-v1:seed=7"; + report.explore.passes[0].sharedObservability = telemetry; + const projected = projectMachineReport(report, "summary"); + assert.strictEqual(projected.explore.sharedObservability[0].eventHistoryComplete, false); + assert.deepStrictEqual(projected.explore.sharedObservability[0].timing.critical, { + identities: 2, + firstAtState: null, + lastAtState: null, + longestDryStates: null, + currentDryStates: null, + identitiesPerMillionTransitions: 2 * 1_000_000 / 12_000, + }); +}); + test("compact shared observability rejects invalid numbers, private pass names, and oversized collections", () => { const report = largeReport(); const secret = "PRIVATE OVERSIZED OBSERVABILITY SECRET ".repeat(10_000); diff --git a/test/search-benchmark.test.js b/test/search-benchmark.test.js index d3423d9..63a39a2 100644 --- a/test/search-benchmark.test.js +++ b/test/search-benchmark.test.js @@ -3,11 +3,13 @@ const assert = require("node:assert"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); +const { createHash } = require("node:crypto"); const { spawnSync } = require("node:child_process"); const { compile, scanKnots } = require("../dist/inklecate"); const { MAX_SHARED_OBSERVABILITY_SAMPLES, + SHARED_OBSERVABILITY_REASON_ORDER_V2, SHARED_SEARCH_CHECKPOINT_SCHEMA_VERSION, explore, explorePortfolio, @@ -60,6 +62,154 @@ const EMPTY_TRUNCATION = { time: false, }; +const SHARED_OBSERVABILITY_REASON_ORDER = [...SHARED_OBSERVABILITY_REASON_ORDER_V2]; +const SHARED_OBSERVABILITY_REASON_RANK = new Map( + SHARED_OBSERVABILITY_REASON_ORDER.map((reason, index) => [reason, index]) +); +const SHARED_OBSERVABILITY_TRIGGER_YIELD_REASONS = SHARED_OBSERVABILITY_REASON_ORDER.slice(1, 9); +const SHARED_OBSERVABILITY_CHECKPOINT_V2_INTEGRITY_DOMAIN = + "inkcheck:shared-observability-checkpoint:v2\0"; + +function sharedObservabilityTriggerMask(reasons) { + return reasons.reduce( + (mask, reason) => mask | (1 << SHARED_OBSERVABILITY_REASON_RANK.get(reason)), + 0 + ); +} + +function sharedObservabilityCheckpointIntegrity(observability) { + const { integritySha256: _integritySha256, ...body } = observability; + return createHash("sha256") + .update(SHARED_OBSERVABILITY_CHECKPOINT_V2_INTEGRITY_DOMAIN) + .update(JSON.stringify(body)) + .digest("hex"); +} + +function refreshSharedObservabilityCheckpointIntegrity(observability) { + observability.integritySha256 = sharedObservabilityCheckpointIntegrity(observability); +} + +function assertSharedObservabilityCheckpointIntegrity(observability) { + assert.match(observability.integritySha256, /^[0-9a-f]{64}$/); + assert.strictEqual( + observability.integritySha256, + sharedObservabilityCheckpointIntegrity(observability) + ); +} + +function assertCanonicalSharedSamples(samples) { + let previousSequence = -1; + let previousState = -1; + for (const [index, sample] of samples.entries()) { + assert.strictEqual(sample.schemaVersion, 2); + assert.ok(Number.isSafeInteger(sample.sequence)); + if (index === 0) assert.strictEqual(sample.sequence, 1, "the first retained sequence must survive compaction"); + assert.ok(sample.sequence > previousSequence, "sample sequences must increase"); + assert.ok(sample.state > previousState, "coalesced sample states must increase"); + assert.ok(sample.reasons.length > 0); + assert.strictEqual(new Set(sample.reasons).size, sample.reasons.length, "sample reasons must be unique"); + assert.ok(sample.reasons.every((reason) => SHARED_OBSERVABILITY_REASON_RANK.has(reason))); + assert.deepStrictEqual( + sample.reasons, + [...sample.reasons].sort( + (left, right) => SHARED_OBSERVABILITY_REASON_RANK.get(left) + - SHARED_OBSERVABILITY_REASON_RANK.get(right) + ), + "sample reasons must use canonical order" + ); + assert.strictEqual( + sample.triggerMask, + sharedObservabilityTriggerMask(sample.reasons), + "the per-boundary trigger mask must match the exact retained reasons" + ); + assert.ok(Array.isArray(sample.triggerYield)); + assert.strictEqual(sample.triggerYield.length, SHARED_OBSERVABILITY_TRIGGER_YIELD_REASONS.length); + assert.ok(sample.triggerYield.every((count) => Number.isSafeInteger(count) && count >= 0)); + assert.deepStrictEqual( + sample.reasons.filter((reason) => SHARED_OBSERVABILITY_TRIGGER_YIELD_REASONS.includes(reason)), + SHARED_OBSERVABILITY_TRIGGER_YIELD_REASONS.filter((_, triggerIndex) => ( + sample.triggerYield[triggerIndex] > 0 + )), + "yield reason bits must match the exact-at-boundary trigger tuple" + ); + previousSequence = sample.sequence; + previousState = sample.state; + } +} + +function withoutSharedObservability(report) { + const copy = structuredClone(report); + for (const pass of copy.passes) delete pass.sharedObservability; + return copy; +} + +function subtractSharedYieldForV1(current, previous) { + return { + critical: { + runtimeErrors: current.critical.runtimeErrors - previous.critical.runtimeErrors, + assertionViolations: current.critical.assertionViolations - previous.critical.assertionViolations, + }, + intent: { + goalsReached: current.intent.goalsReached - previous.intent.goalsReached, + stagesReached: current.intent.stagesReached - previous.intent.stagesReached, + }, + authoredCoverage: { + knotsVisited: current.authoredCoverage.knotsVisited - previous.authoredCoverage.knotsVisited, + }, + visibleOutcomes: current.visibleOutcomes - previous.visibleOutcomes, + semanticTransitions: current.semanticTransitions - previous.semanticTransitions, + terminalVariants: current.terminalVariants - previous.terminalVariants, + rawTerritory: { + transitions: current.rawTerritory.transitions - previous.rawTerritory.transitions, + uniqueStates: current.rawTerritory.uniqueStates - previous.rawTerritory.uniqueStates, + dedupeHits: current.rawTerritory.dedupeHits - previous.rawTerritory.dedupeHits, + }, + }; +} + +function withV1SharedObservability(checkpoint) { + const migrated = structuredClone(checkpoint); + const v2 = migrated.state.sharedObservability; + const cadenceSamples = v2.samples.filter((sample) => sample.reasons.includes("cadence")); + let previousState = v2.baseState; + let previousYield = v2.baseYield; + const samples = cadenceSamples.map((sample) => { + const cumulative = structuredClone(sample.yield.cumulative); + const converted = { + schemaVersion: 1, + boundary: "interval", + state: sample.state, + retention: structuredClone(sample.retention), + yield: { + schemaVersion: 1, + fromStateExclusive: previousState, + throughState: sample.state, + delta: subtractSharedYieldForV1(cumulative, previousYield), + cumulative, + }, + }; + previousState = sample.state; + previousYield = cumulative; + return converted; + }); + migrated.state.sharedObservability = { + schemaVersion: 1, + sampleIntervalStates: v2.sampleIntervalStates, + samplesRecorded: v2.cadenceSamplesRecorded, + samples, + baseState: v2.baseState, + baseYield: structuredClone(v2.baseYield), + previousSampleState: previousState, + previousYield: structuredClone(previousYield), + nextSampleState: v2.nextSampleState, + firstUsefulAtState: v2.firstUsefulAtState, + firstCriticalAtState: v2.firstCriticalAtState, + throughFirstUseful: structuredClone(v2.throughFirstUseful), + historyComplete: v2.historyComplete, + }; + return migrated; +} + function ending(variables) { return { path: ["Finish"], @@ -519,16 +669,34 @@ test("shared observability keeps deterministic retention and yield separate from sharedObservabilityIntervalStates: 1, onSharedObservability: (observation) => observations.push(observation), }); + const sparseCadenceReport = exploreShared(compiled.storyJson, scanKnots(LOW_DEDUP_WIDE), [], { + maxDepth: 150, + maxStates: 300, + seed: 7, + sharedObservabilityIntervalStates: 10_000_000, + }); + assert.deepStrictEqual( + withoutSharedObservability(report), + withoutSharedObservability(sparseCadenceReport), + "observability cadence must not change findings or their order" + ); const telemetry = report.passes[0].sharedObservability; - assert.strictEqual(telemetry.schemaVersion, 1); + assert.strictEqual(telemetry.schemaVersion, 2); assert.strictEqual(telemetry.sampleIntervalStates, 1); - assert.strictEqual(telemetry.samplesRecorded, report.statesExplored); + assert.strictEqual(telemetry.cadenceSamplesRecorded, report.statesExplored); + assert.ok(telemetry.samplesRecorded >= telemetry.cadenceSamplesRecorded); assert.ok(telemetry.samplesRetained <= MAX_SHARED_OBSERVABILITY_SAMPLES); assert.strictEqual(telemetry.samplesCompacted, telemetry.samplesRecorded - telemetry.samplesRetained); assert.ok(telemetry.samplesCompacted > 0); - assert.strictEqual(telemetry.samples[0].state, 1); assert.strictEqual(telemetry.samples.at(-1).state, report.statesExplored); - assert.strictEqual(telemetry.samples.at(-1).boundary, "interval_and_termination"); + assert.deepStrictEqual( + telemetry.samples.at(-1).reasons.filter((reason) => reason === "cadence" || reason === "termination"), + ["cadence", "termination"] + ); + assert.strictEqual(telemetry.historyComplete, true); + assert.strictEqual(telemetry.eventHistoryComplete, true); + assert.strictEqual(telemetry.timing.schemaVersion, 2); + assertCanonicalSharedSamples(telemetry.samples); assert.deepStrictEqual( telemetry.samples.at(-1).yield.cumulative, telemetry.yieldSummary.cumulative @@ -538,16 +706,21 @@ test("shared observability keeps deterministic retention and yield separate from assert.ok(telemetry.yieldSummary.afterFirstUseful.rawTerritory.transitions > 0); assert.ok(telemetry.yieldSummary.cumulative.semanticTransitions < report.passes[0].variableTransitionsObserved); assert.strictEqual("score" in telemetry.yieldSummary, false); + assert.strictEqual(observations.length, report.statesExplored); + assert.strictEqual( + new Set(observations.map((observation) => `${observation.sample.sequence}:${observation.sample.state}`)).size, + observations.length, + "live cadence observations must be final one-per-state values, not same-state revisions" + ); for (let index = 1; index < telemetry.samples.length; index++) { - assert.ok(telemetry.samples[index].state > telemetry.samples[index - 1].state); assert.strictEqual( telemetry.samples[index].yield.fromStateExclusive, telemetry.samples[index - 1].state ); } const latest = observations.at(-1); - assert.strictEqual(latest.schemaVersion, 1); + assert.strictEqual(latest.schemaVersion, 2); assert.strictEqual(latest.runWideState, latest.sample.state); assert.strictEqual(latest.process.schemaVersion, 1); assert.strictEqual(latest.process.scope, "process"); @@ -560,6 +733,176 @@ test("shared observability keeps deterministic retention and yield separate from assert.ok(Number.isInteger(latest.process.unattributedBytes)); assert.doesNotMatch(JSON.stringify(observations), /path_code|wide tree leaf|"Left"|"Center"|"Right"/i); assert.doesNotMatch(JSON.stringify(report), /heapUsedBytes|heapTotalBytes|rssBytes|unattributedBytes/); + + const sparseLive = []; + const eventHeavy = exploreShared(compiled.storyJson, scanKnots(LOW_DEDUP_WIDE), [], { + maxDepth: 150, + maxStates: 1_000, + seed: 7, + onSharedObservability: (observation) => sparseLive.push(observation), + }); + assert.ok(eventHeavy.passes[0].sharedObservability.samplesRecorded > sparseLive.length); + assert.strictEqual(sparseLive.length, 1, "off-cadence category events must not amplify live output"); + assert.deepStrictEqual(sparseLive[0].sample.reasons.at(-1), "termination"); +}); + +test("shared observability records every supported event reason off cadence", async () => { + const interval = 10_000_000; + const reports = []; + const lock = await compile(LOCK); + const lockKnots = scanKnots(LOCK); + reports.push(exploreShared(lock.storyJson, lockKnots, [], { + maxDepth: 150, + maxStates: 1_000, + seed: 7, + sharedObservabilityIntervalStates: interval, + assertions: [{ + id: "a_stays_zero", + when: "always", + condition: { left: { variable: "a" }, operator: "==", right: { literal: 0 } }, + }], + goals: [{ + id: "a_chosen", + condition: { left: { variable: "a" }, operator: ">", right: { literal: 0 } }, + }, { + id: "a_then_b", + stages: [{ + id: "choose_a", + condition: { left: { variable: "a" }, operator: ">", right: { literal: 0 } }, + }, { + id: "choose_b", + condition: { left: { variable: "b" }, operator: ">", right: { literal: 0 } }, + }], + }], + })); + + const plateau = await compile(PLATEAU); + reports.push(exploreShared(plateau.storyJson, scanKnots(PLATEAU), [], { + maxDepth: 150, + maxStates: 500, + seed: 7, + sharedObservabilityIntervalStates: interval, + })); + + const wide = await compile(LOW_DEDUP_WIDE); + const wideKnots = scanKnots(LOW_DEDUP_WIDE); + reports.push(exploreShared(wide.storyJson, wideKnots, [], { + maxDepth: 150, + maxStates: 1_500, + seed: 7, + sharedObservabilityIntervalStates: interval, + })); + reports.push(exploreShared(wide.storyJson, wideKnots, [], { + maxDepth: 150, + maxStates: 100, + seed: 7, + sharedMaxPendingStates: 1, + sharedObservabilityIntervalStates: interval, + })); + + for (const report of reports) { + const telemetry = report.passes[0].sharedObservability; + assert.strictEqual(telemetry.cadenceSamplesRecorded, 0); + assertCanonicalSharedSamples(telemetry.samples); + } + const supportedOffCadenceReasons = [ + "runtime_error", + "assertion_violation", + "goal_reached", + "stage_reached", + "authored_knot", + "visible_outcome", + "semantic_transition", + "terminal_variant", + "frontier_compaction", + "frontier_ceiling", + "termination", + ]; + const recorded = reports.flatMap((report) => report.passes[0].sharedObservability.samples); + for (const reason of supportedOffCadenceReasons) { + const sample = recorded.find((candidate) => + candidate.reasons.includes(reason) && candidate.state % interval !== 0 + ); + assert.ok(sample, `expected an independently triggered ${reason} boundary`); + assert.strictEqual(sample.reasons.includes("cadence"), false); + } +}); + +test("shared observability coalesces cadence, events, and termination deterministically", async () => { + const compiled = await compile(LOCK); + const knots = scanKnots(LOCK); + const options = { + maxDepth: 150, + maxStates: 1, + seed: 7, + sharedObservabilityIntervalStates: 1, + assertions: [{ + id: "a_stays_zero", + when: "always", + condition: { left: { variable: "a" }, operator: "==", right: { literal: 0 } }, + }], + goals: [{ + id: "a_chosen", + condition: { left: { variable: "a" }, operator: ">", right: { literal: 0 } }, + }], + }; + const first = exploreShared(compiled.storyJson, knots, [], options); + const second = exploreShared(compiled.storyJson, knots, [], options); + const firstTelemetry = first.passes[0].sharedObservability; + const secondTelemetry = second.passes[0].sharedObservability; + assert.deepStrictEqual(firstTelemetry, secondTelemetry); + assertCanonicalSharedSamples(firstTelemetry.samples); + + const coincident = firstTelemetry.samples.filter((sample) => sample.state === 1); + assert.strictEqual(coincident.length, 1, "one state must retain one coalesced sample"); + for (const reason of [ + "cadence", + "assertion_violation", + "goal_reached", + "semantic_transition", + "termination", + ]) { + assert.ok(coincident[0].reasons.includes(reason), `coincident sample should include ${reason}`); + } + assert.strictEqual(firstTelemetry.samplesRecorded, firstTelemetry.samplesRetained); + assert.strictEqual(firstTelemetry.cadenceSamplesRecorded, 1); +}); + +test("complete adjacent V2 boundaries require exact semantic trigger yield", async () => { + const compiled = await compile(LOCK); + const knots = scanKnots(LOCK); + const options = { + maxDepth: 150, + maxStates: 10, + seed: 7, + sharedObservabilityIntervalStates: 1, + }; + const checkpoint = exploreSharedResumable( + compiled.storyJson, + knots, + [], + options + ).checkpoint; + const observability = checkpoint.state.sharedObservability; + assert.strictEqual(observability.eventHistoryComplete, true); + const adjacent = observability.samples.find((sample) => sample.sequence === 7); + assert.ok(adjacent); + assert.strictEqual(adjacent.triggerYield[4], 2); + assert.strictEqual(adjacent.yield.delta.authoredCoverage.knotsVisited, 2); + + const rehashedUndercount = structuredClone(checkpoint); + rehashedUndercount.state.sharedObservability.samples + .find((sample) => sample.sequence === 7).triggerYield[4] = 1; + refreshSharedObservabilityCheckpointIntegrity( + rehashedUndercount.state.sharedObservability + ); + assert.throws( + () => exploreSharedResumable(compiled.storyJson, knots, [], { + ...options, + maxStates: 11, + }, rehashedUndercount), + /shared observability v2 samples are malformed or out of order/ + ); }); test("base shared search resumes from JSON with the exact uninterrupted result", async () => { @@ -574,7 +917,8 @@ test("base shared search resumes from JSON with the exact uninterrupted result", sharedObservabilityIntervalStates: 25, }; const uninterrupted = exploreSharedResumable(compiled.storyJson, knots, [], options); - assert.deepStrictEqual(uninterrupted.result, exploreShared(compiled.storyJson, knots, [], options)); + const direct = exploreShared(compiled.storyJson, knots, [], options); + assert.deepStrictEqual(uninterrupted.result, direct); const first = exploreSharedResumable(compiled.storyJson, knots, [], { ...options, maxStates: 73, @@ -585,16 +929,233 @@ test("base shared search resumes from JSON with the exact uninterrupted result", assert.ok(first.checkpoint.state.current.cursor > 0, "fixture should pause partway through a choice list"); assert.strictEqual(first.checkpoint.state.truncatedBy.maxStates, false); assert.strictEqual(first.result.truncatedBy.maxStates, true); - assert.deepStrictEqual(first.checkpoint.state.sharedObservability.samples.map((sample) => sample.state), [25, 50]); + assert.strictEqual(first.checkpoint.state.sharedObservability.schemaVersion, 2); + assert.deepStrictEqual( + first.checkpoint.state.sharedObservability.samples + .filter((sample) => sample.reasons.includes("cadence")) + .map((sample) => sample.state), + [25, 50] + ); + assert.ok(first.checkpoint.state.sharedObservability.samples + .every((sample) => !sample.reasons.includes("checkpoint"))); assert.doesNotMatch(JSON.stringify(first.checkpoint), /heapUsedBytes|heapTotalBytes|rssBytes|unattributedBytes/); + assertSharedObservabilityCheckpointIntegrity(first.checkpoint.state.sharedObservability); + const firstReplay = exploreSharedResumable(compiled.storyJson, knots, [], { + ...options, + maxStates: 73, + }); + assert.deepStrictEqual(firstReplay.checkpoint, first.checkpoint); + assert.strictEqual( + firstReplay.checkpoint.state.sharedObservability.integritySha256, + first.checkpoint.state.sharedObservability.integritySha256, + "the same logical checkpoint must have one stable integrity identity" + ); + + const reordered = structuredClone(first.checkpoint); + const reorderedObservability = reordered.state.sharedObservability; + reorderedObservability.samples[0] = Object.fromEntries( + Object.entries(reorderedObservability.samples[0]).reverse() + ); + reordered.state.sharedObservability = Object.fromEntries( + Object.entries(reorderedObservability).reverse() + ); + const reorderedResume = exploreSharedResumable(compiled.storyJson, knots, [], { + ...options, + maxStates: 74, + }, reordered); + const reemittedObservability = reorderedResume.checkpoint.state.sharedObservability; + assertSharedObservabilityCheckpointIntegrity(reemittedObservability); + assert.deepStrictEqual( + Object.keys(reemittedObservability), + Object.keys(first.checkpoint.state.sharedObservability), + "resuming reordered raw objects must re-emit the canonical envelope order" + ); + assert.deepStrictEqual( + Object.keys(reemittedObservability.samples[0]), + Object.keys(first.checkpoint.state.sharedObservability.samples[0]), + "resuming reordered raw objects must re-emit canonical sample order" + ); const serialized = JSON.parse(JSON.stringify(first.checkpoint)); const resumed = exploreSharedResumable(compiled.storyJson, knots, [], options, serialized); assert.deepStrictEqual(resumed, uninterrupted); + const uninterruptedTelemetry = uninterrupted.result.passes[0].sharedObservability; + const resumedTelemetry = resumed.result.passes[0].sharedObservability; + assert.ok(uninterruptedTelemetry.samplesCompacted > 0); + assert.ok(resumedTelemetry.samplesCompacted > 0); + assert.deepStrictEqual(resumedTelemetry.timing, uninterruptedTelemetry.timing); + assert.deepStrictEqual(resumedTelemetry.yieldSummary, uninterruptedTelemetry.yieldSummary); + assertCanonicalSharedSamples(resumedTelemetry.samples); assert.strictEqual(resumed.checkpoint.state.totalGranted, 500); }); -test("shared checkpoints preserve useful milestones before the first retained sample", async () => { +test("V1 observability migrates once and remains deterministic across V2 split resumes", async () => { + const compiled = await compile(LOW_DEDUP_WIDE); + const knots = scanKnots(LOW_DEDUP_WIDE); + const options = { + maxDepth: 150, + maxStates: 500, + seed: 7, + preserveTurnState: false, + preserveRandomState: false, + sharedObservabilityIntervalStates: 25, + }; + const native = exploreSharedResumable(compiled.storyJson, knots, [], options); + const initial = exploreSharedResumable(compiled.storyJson, knots, [], { + ...options, + maxStates: 73, + }); + const v1Checkpoint = withV1SharedObservability(initial.checkpoint); + assert.strictEqual(v1Checkpoint.state.sharedObservability.schemaVersion, 1); + assert.strictEqual("integritySha256" in v1Checkpoint.state.sharedObservability, false); + assert.deepStrictEqual( + v1Checkpoint.state.sharedObservability.samples.map((sample) => sample.state), + [25, 50] + ); + + const migrated = exploreSharedResumable(compiled.storyJson, knots, [], { + ...options, + maxStates: 73, + }, JSON.parse(JSON.stringify(v1Checkpoint))); + const migratedObservability = migrated.checkpoint.state.sharedObservability; + assert.strictEqual(migratedObservability.schemaVersion, 2); + assertSharedObservabilityCheckpointIntegrity(migratedObservability); + assert.strictEqual(migratedObservability.eventHistoryComplete, false); + assertCanonicalSharedSamples(migratedObservability.samples); + assert.ok(migratedObservability.samples.every((sample) => ( + sample.triggerYield.every((count) => count === 0) + )), "V1 cadence migration records no invented event trigger evidence"); + assert.strictEqual(migratedObservability.nextSampleState, v1Checkpoint.state.sharedObservability.nextSampleState); + assert.strictEqual( + migratedObservability.cadenceSamplesRecorded, + v1Checkpoint.state.sharedObservability.samplesRecorded + ); + for (const state of [25, 50]) { + assert.deepStrictEqual( + migratedObservability.samples.find((sample) => sample.state === state).reasons, + ["cadence"] + ); + } + + const runSplit = () => { + const middle = exploreSharedResumable(compiled.storyJson, knots, [], { + ...options, + maxStates: 211, + }, structuredClone(migrated.checkpoint)); + assertSharedObservabilityCheckpointIntegrity(middle.checkpoint.state.sharedObservability); + assert.ok(middle.checkpoint.state.sharedObservability.samples + .every((sample) => !sample.reasons.includes("checkpoint"))); + return exploreSharedResumable(compiled.storyJson, knots, [], options, + JSON.parse(JSON.stringify(middle.checkpoint))); + }; + const splitA = runSplit(); + const splitB = runSplit(); + assert.deepStrictEqual(splitA, splitB); + assert.deepStrictEqual( + withoutSharedObservability(splitA.result), + withoutSharedObservability(native.result) + ); + const splitTelemetry = splitA.result.passes[0].sharedObservability; + assert.strictEqual(splitTelemetry.eventHistoryComplete, false); + assert.ok(splitTelemetry.samplesCompacted > 0); + assert.deepStrictEqual(splitTelemetry.timing, splitA.checkpoint.state.sharedObservability.timing); + assertCanonicalSharedSamples(splitTelemetry.samples); +}); + +test("shared observability sequence ordinals stay bounded across compaction and V1 two-hop migration", async () => { + const compiled = await compile(LOW_DEDUP_WIDE); + const knots = scanKnots(LOW_DEDUP_WIDE); + const options = { + maxDepth: 150, + maxStates: 300, + seed: 7, + sharedObservabilityIntervalStates: 1, + }; + const native = exploreSharedResumable(compiled.storyJson, knots, [], options); + const checkpoint = native.checkpoint; + const observability = checkpoint.state.sharedObservability; + assert.strictEqual(checkpoint.state.statesExplored, 300); + assert.strictEqual(observability.samplesRecorded, 300); + assert.strictEqual(observability.cadenceSamplesRecorded, 300); + assert.ok(observability.samples.length <= MAX_SHARED_OBSERVABILITY_SAMPLES); + assert.ok(observability.samples.length < observability.samplesRecorded); + assertCanonicalSharedSamples(observability.samples); + assert.ok(observability.samples.every((sample) => sample.sequence === sample.state)); + + const rejectOrdinalTamper = (mutate) => { + const tampered = structuredClone(checkpoint); + mutate(tampered.state.sharedObservability); + assert.throws( + () => exploreSharedResumable(compiled.storyJson, knots, [], { + ...options, + maxStates: 350, + }, tampered), + /shared observability/ + ); + }; + rejectOrdinalTamper((value) => { + value.samplesRecorded = 301; + value.samples.at(-1).sequence = 301; + }); + rejectOrdinalTamper((value) => { + const interior = value.samples.find((sample) => sample.sequence === 9); + assert.ok(interior, "interval-one compaction fixture must retain ordinal 9"); + interior.sequence = 8; + }); + + const preCompaction = exploreSharedResumable(compiled.storyJson, knots, [], { + ...options, + maxStates: 20, + }).checkpoint; + const preCompactionObservability = preCompaction.state.sharedObservability; + assert.strictEqual(preCompactionObservability.samplesRecorded, 20); + assert.strictEqual(preCompactionObservability.samples.length, 20); + const missingInterior = structuredClone(preCompaction); + missingInterior.state.sharedObservability.samples.splice(8, 1); + assert.throws( + () => exploreSharedResumable(compiled.storyJson, knots, [], { + ...options, + maxStates: 30, + }, missingInterior), + /shared observability/ + ); + + const compactedV1 = withV1SharedObservability(checkpoint); + assert.strictEqual(compactedV1.state.sharedObservability.schemaVersion, 1); + assert.strictEqual(compactedV1.state.sharedObservability.samplesRecorded, 300); + assert.ok(compactedV1.state.sharedObservability.samples.length < 300); + const runTwoHop = () => { + const firstHop = exploreSharedResumable(compiled.storyJson, knots, [], { + ...options, + maxStates: 350, + }, structuredClone(compactedV1)); + const firstHopObservability = firstHop.checkpoint.state.sharedObservability; + assert.strictEqual(firstHopObservability.eventHistoryComplete, false); + assertCanonicalSharedSamples(firstHopObservability.samples); + assert.ok(firstHopObservability.samples.every((sample) => sample.sequence === sample.state)); + assert.ok(firstHopObservability.samples + .filter((sample) => sample.state <= 300) + .every((sample) => sample.triggerYield.every((count) => count === 0))); + const secondHop = exploreSharedResumable(compiled.storyJson, knots, [], { + ...options, + maxStates: 400, + }, JSON.parse(JSON.stringify(firstHop.checkpoint))); + assertCanonicalSharedSamples(secondHop.checkpoint.state.sharedObservability.samples); + assert.ok(secondHop.checkpoint.state.sharedObservability.samples + .every((sample) => sample.sequence === sample.state)); + return secondHop; + }; + const first = runTwoHop(); + const second = runTwoHop(); + assert.deepStrictEqual(first, second); + const direct = exploreSharedResumable(compiled.storyJson, knots, [], { + ...options, + maxStates: 400, + }); + assert.deepStrictEqual(withoutSharedObservability(first.result), withoutSharedObservability(direct.result)); +}); + +test("shared checkpoints preserve useful milestones before the first cadence sample", async () => { const compiled = await compile(AUTHORED_DOG); const knots = scanKnots(AUTHORED_DOG); const options = { @@ -610,13 +1171,15 @@ test("shared checkpoints preserve useful milestones before the first retained sa assert.strictEqual(observability.baseState, 0); assert.strictEqual(observability.sampleIntervalStates, 10); assert.strictEqual(observability.firstUsefulAtState, 1); - assert.strictEqual(observability.samples[0].state, 10); - assert.ok(observability.samples[0].yield.cumulative.authoredCoverage.knotsVisited > 0); - assert.ok(observability.firstUsefulAtState < observability.samples[0].state); - assert.ok( - observability.throughFirstUseful.authoredCoverage.knotsVisited - <= observability.samples[0].yield.cumulative.authoredCoverage.knotsVisited - ); + const firstEvent = observability.samples[0]; + const firstCadence = observability.samples.find((sample) => sample.reasons.includes("cadence")); + assert.strictEqual(firstEvent.state, 1); + assert.strictEqual(firstEvent.reasons.includes("cadence"), false); + assert.strictEqual(firstCadence.state, 10); + assert.ok(firstCadence.yield.cumulative.authoredCoverage.knotsVisited > 0); + assert.strictEqual(observability.firstUsefulAtState, firstEvent.state); + assert.ok(observability.firstUsefulAtState < firstCadence.state); + assert.deepStrictEqual(observability.throughFirstUseful, firstEvent.yield.cumulative); const resumed = exploreSharedResumable(compiled.storyJson, knots, [], { ...options, @@ -625,24 +1188,25 @@ test("shared checkpoints preserve useful milestones before the first retained sa assert.ok(resumed.result.statesExplored >= first.result.statesExplored); const atFirstSample = structuredClone(first.checkpoint); - atFirstSample.state.sharedObservability.firstUsefulAtState = 10; - atFirstSample.state.sharedObservability.throughFirstUseful.rawTerritory.transitions = 10; assert.doesNotThrow(() => exploreSharedResumable(compiled.storyJson, knots, [], { ...options, maxStates: 200, }, atFirstSample)); const exceedsFirstSample = structuredClone(first.checkpoint); - const firstSampleUniqueStates = exceedsFirstSample.state.sharedObservability - .samples[0].yield.cumulative.rawTerritory.uniqueStates; + const firstCadenceUniqueStates = exceedsFirstSample.state.sharedObservability + .samples.find((sample) => sample.reasons.includes("cadence")) + .yield.cumulative.rawTerritory.uniqueStates; assert.ok( - firstSampleUniqueStates + firstCadenceUniqueStates < exceedsFirstSample.state.sharedObservability.previousYield.rawTerritory.uniqueStates ); - exceedsFirstSample.state.sharedObservability.firstUsefulAtState = 10; - exceedsFirstSample.state.sharedObservability.throughFirstUseful.rawTerritory.transitions = 10; + const firstEventUniqueStates = exceedsFirstSample.state.sharedObservability.samples + .find((sample) => sample.state === exceedsFirstSample.state.sharedObservability.firstUsefulAtState) + .yield.cumulative.rawTerritory.uniqueStates; exceedsFirstSample.state.sharedObservability.throughFirstUseful.rawTerritory.uniqueStates - = firstSampleUniqueStates + 1; + = firstEventUniqueStates + 1; + refreshSharedObservabilityCheckpointIntegrity(exceedsFirstSample.state.sharedObservability); assert.throws( () => exploreSharedResumable(compiled.storyJson, knots, [], { ...options, @@ -651,10 +1215,11 @@ test("shared checkpoints preserve useful milestones before the first retained sa /useful milestone exceeds retained cumulative yield/ ); - for (const lateMilestone of [11, 50]) { + for (const lateMilestone of [2, 50]) { const tampered = structuredClone(first.checkpoint); tampered.state.sharedObservability.firstUsefulAtState = lateMilestone; tampered.state.sharedObservability.throughFirstUseful.rawTerritory.transitions = lateMilestone; + refreshSharedObservabilityCheckpointIntegrity(tampered.state.sharedObservability); assert.throws( () => exploreSharedResumable(compiled.storyJson, knots, [], { ...options, @@ -736,9 +1301,10 @@ test("shared checkpoints fail closed on incompatible source, options, budget, an ); const badObservability = clone(); badObservability.state.sharedObservability.baseYield.rawTerritory.transitions = -1; + refreshSharedObservabilityCheckpointIntegrity(badObservability.state.sharedObservability); assert.throws( () => exploreSharedResumable(compiled.storyJson, knots, [], { ...options, maxStates: 100 }, badObservability), - /shared observability ledger is malformed/ + /shared observability(?: v2)? ledger is malformed/ ); const observedOptions = { ...options, sharedObservabilityIntervalStates: 10 }; const observedCheckpoint = exploreSharedResumable( @@ -750,9 +1316,45 @@ test("shared checkpoints fail closed on incompatible source, options, budget, an assert.ok(observedCheckpoint.state.sharedObservability.samples.length >= 2); assert.strictEqual(observedCheckpoint.state.runtimeErrors.length, 0); assert.ok(observedCheckpoint.state.sharedObservability.firstUsefulAtState !== null); + assertSharedObservabilityCheckpointIntegrity(observedCheckpoint.state.sharedObservability); + for (const mutate of [ + (observability) => { delete observability.integritySha256; }, + (observability) => { observability.integritySha256 = "0".repeat(63); }, + (observability) => { observability.integritySha256 = "G".repeat(64); }, + (observability) => { observability.unrecognized = true; }, + ]) { + const malformedIntegrity = structuredClone(observedCheckpoint); + mutate(malformedIntegrity.state.sharedObservability); + assert.throws( + () => exploreSharedResumable(compiled.storyJson, knots, [], { + ...observedOptions, + maxStates: 100, + }, malformedIntegrity), + /integrity checksum is missing or malformed/ + ); + } + const staleValidLookingIntegrity = structuredClone(observedCheckpoint); + staleValidLookingIntegrity.state.sharedObservability.integritySha256 = "0".repeat(64); + assert.throws( + () => exploreSharedResumable(compiled.storyJson, knots, [], { + ...observedOptions, + maxStates: 100, + }, staleValidLookingIntegrity), + /integrity checksum mismatch/ + ); + const malformedCanonicalShape = structuredClone(observedCheckpoint); + malformedCanonicalShape.state.sharedObservability.samples[0].yield.unrecognized = true; + assert.throws( + () => exploreSharedResumable(compiled.storyJson, knots, [], { + ...observedOptions, + maxStates: 100, + }, malformedCanonicalShape), + /canonical shape is malformed/ + ); const rejectObservedTamper = (mutate) => { const tampered = structuredClone(observedCheckpoint); mutate(tampered.state.sharedObservability, tampered.state); + refreshSharedObservabilityCheckpointIntegrity(tampered.state.sharedObservability); assert.throws( () => exploreSharedResumable(compiled.storyJson, knots, [], { ...observedOptions, @@ -801,7 +1403,177 @@ test("shared checkpoints fail closed on incompatible source, options, budget, an observability.firstCriticalAtState = 1; }); rejectObservedTamper((observability) => { - observability.samples[0].boundary = "termination"; + observability.samples[0].reasons = ["unknown_reason"]; + }); + rejectObservedTamper((observability) => { + observability.samples[0].reasons = ["termination", "cadence"]; + }); + rejectObservedTamper((observability) => { + observability.samples[0].reasons = ["cadence", "cadence"]; + }); + rejectObservedTamper((observability) => { + const first = observability.samples.find((sample) => sample.sequence === 1); + assert.ok(first); + const reason = first.reasons.find((candidate) => [ + "runtime_error", "assertion_violation", "goal_reached", "stage_reached", + "authored_knot", "visible_outcome", "semantic_transition", "terminal_variant", + ].includes(candidate)); + assert.ok(reason); + first.reasons = first.reasons.filter((candidate) => candidate !== reason); + }); + for (const reservedReason of ["checkpoint", "epoch", "pressure"]) { + rejectObservedTamper((observability) => { + observability.samples[0].reasons = [ + ...observability.samples[0].reasons, + reservedReason, + ].sort((left, right) => SHARED_OBSERVABILITY_REASON_RANK.get(left) + - SHARED_OBSERVABILITY_REASON_RANK.get(right)); + }); + } + + const compactedOptions = { ...observedOptions, maxStates: 500 }; + const compactedCheckpoint = exploreSharedResumable( + compiled.storyJson, + knots, + [], + compactedOptions + ).checkpoint; + const compactedObservability = compactedCheckpoint.state.sharedObservability; + assert.ok(compactedObservability.samplesRecorded > compactedObservability.samples.length); + assertCanonicalSharedSamples(compactedObservability.samples); + const hasSequenceGapBefore = (samples, index) => ( + index > 0 && samples[index].sequence > samples[index - 1].sequence + 1 + ); + const removalIndex = compactedObservability.samples.findIndex((sample, index, samples) => ( + hasSequenceGapBefore(samples, index) + && sample.reasons.filter((reason) => [ + "runtime_error", "assertion_violation", "goal_reached", "stage_reached", + "authored_knot", "visible_outcome", "semantic_transition", "terminal_variant", + ].includes(reason)).length >= 2 + )); + const additionIndex = compactedObservability.samples.findIndex((sample, index, samples) => ( + hasSequenceGapBefore(samples, index) + && !sample.reasons.includes("terminal_variant") + && sample.yield.delta.terminalVariants > 0 + )); + assert.ok(removalIndex > 0, "fixture must retain a multi-reason boundary after a compaction gap"); + assert.ok(additionIndex > 0, "fixture must aggregate a removed terminal trigger before a retained boundary"); + const removeCompactedBoundaryTrigger = (observability) => { + const sample = observability.samples[removalIndex]; + const removedReason = sample.reasons.find((reason) => ( + SHARED_OBSERVABILITY_TRIGGER_YIELD_REASONS.includes(reason) + )); + assert.ok(removedReason); + const triggerIndex = SHARED_OBSERVABILITY_TRIGGER_YIELD_REASONS.indexOf(removedReason); + sample.reasons = sample.reasons.filter((reason) => reason !== removedReason); + sample.triggerMask = sharedObservabilityTriggerMask(sample.reasons); + sample.triggerYield[triggerIndex] = 0; + }; + const coordinatedStaleIntegrity = structuredClone(compactedCheckpoint); + removeCompactedBoundaryTrigger(coordinatedStaleIntegrity.state.sharedObservability); + assert.throws( + () => exploreSharedResumable(compiled.storyJson, knots, [], { + ...compactedOptions, + maxStates: 550, + }, coordinatedStaleIntegrity), + /integrity checksum mismatch/, + "a stale digest must reject a structurally self-consistent compacted reason, mask, and tuple edit" + ); + const coordinatedRehashedIntegrity = structuredClone(compactedCheckpoint); + removeCompactedBoundaryTrigger(coordinatedRehashedIntegrity.state.sharedObservability); + refreshSharedObservabilityCheckpointIntegrity( + coordinatedRehashedIntegrity.state.sharedObservability + ); + assert.doesNotThrow( + () => exploreSharedResumable(compiled.storyJson, knots, [], { + ...compactedOptions, + maxStates: 550, + }, coordinatedRehashedIntegrity), + "the self-check is integrity, not authentication: a structurally valid writer can recompute it" + ); + const staleCursorIntegrity = structuredClone(compactedCheckpoint); + const staleCursorObservability = staleCursorIntegrity.state.sharedObservability; + staleCursorObservability.samples[removalIndex].sequence++; + staleCursorObservability.samples[removalIndex].state++; + staleCursorObservability.samplesRecorded++; + assert.throws( + () => exploreSharedResumable(compiled.storyJson, knots, [], { + ...compactedOptions, + maxStates: 550, + }, staleCursorIntegrity), + /integrity checksum mismatch/, + "sequence, state, and aggregate counters are independently bound" + ); + const rehashedInvalidTrigger = structuredClone(compactedCheckpoint); + rehashedInvalidTrigger.state.sharedObservability.samples[removalIndex].triggerYield[0] = -1; + refreshSharedObservabilityCheckpointIntegrity( + rehashedInvalidTrigger.state.sharedObservability + ); + assert.throws( + () => exploreSharedResumable(compiled.storyJson, knots, [], { + ...compactedOptions, + maxStates: 550, + }, rehashedInvalidTrigger), + /shared observability v2 samples are malformed or out of order/, + "a recomputed checksum must never substitute for semantic validation" + ); + const rejectCompactedTamper = (mutate) => { + const tampered = structuredClone(compactedCheckpoint); + mutate(tampered.state.sharedObservability); + refreshSharedObservabilityCheckpointIntegrity(tampered.state.sharedObservability); + assert.throws( + () => exploreSharedResumable(compiled.storyJson, knots, [], { + ...compactedOptions, + maxStates: 550, + }, tampered), + /shared observability/ + ); + }; + rejectCompactedTamper((observability) => { + observability.samplesRecorded++; + for (const sample of observability.samples) sample.sequence++; + }); + rejectCompactedTamper((observability) => { + const sample = observability.samples[removalIndex]; + const removed = sample.reasons.find((reason) => reason !== "terminal_variant"); + assert.ok(removed); + sample.reasons = sample.reasons.filter((reason) => reason !== removed); + }); + rejectCompactedTamper((observability) => { + const sample = observability.samples[additionIndex]; + sample.reasons = [...sample.reasons, "terminal_variant"].sort( + (left, right) => SHARED_OBSERVABILITY_REASON_RANK.get(left) + - SHARED_OBSERVABILITY_REASON_RANK.get(right) + ); + }); + rejectCompactedTamper((observability) => { + const sample = observability.samples[removalIndex]; + const removed = sample.reasons.find((reason) => reason !== "terminal_variant"); + assert.ok(removed); + sample.reasons = sample.reasons.filter((reason) => reason !== removed); + sample.triggerMask = sharedObservabilityTriggerMask(sample.reasons); + }); + rejectCompactedTamper((observability) => { + const sample = observability.samples[additionIndex]; + sample.reasons = [...sample.reasons, "terminal_variant"].sort( + (left, right) => SHARED_OBSERVABILITY_REASON_RANK.get(left) + - SHARED_OBSERVABILITY_REASON_RANK.get(right) + ); + sample.triggerMask = sharedObservabilityTriggerMask(sample.reasons); + }); + rejectCompactedTamper((observability) => { + const sample = observability.samples[removalIndex]; + sample.triggerYield[7] = sample.yield.delta.terminalVariants + 1; + }); + const originalMask = compactedObservability.samples[removalIndex].triggerMask; + for (const invalidMask of [originalMask ^ 1, 1.5, 2 ** SHARED_OBSERVABILITY_REASON_ORDER.length]) { + rejectCompactedTamper((observability) => { + observability.samples[removalIndex].triggerMask = invalidMask; + }); + } + rejectObservedTamper((observability) => { + observability.eventHistoryComplete = false; + observability.timing.critical.longestDryStates = 1; }); rejectObservedTamper((observability) => { observability.samplesRecorded = 1_000_000; @@ -822,11 +1594,35 @@ test("shared checkpoints fail closed on incompatible source, options, budget, an compiled.storyJson, knots, [], - { ...options, maxStates: 100 }, + { ...options, maxStates: 74 }, legacyCheckpoint ); assert.strictEqual(legacyResume.result.passes[0].sharedObservability.historyComplete, false); - assert.strictEqual(legacyResume.result.statesExplored, 100); + assert.strictEqual(legacyResume.result.passes[0].sharedObservability.eventHistoryComplete, false); + assert.strictEqual(legacyResume.result.statesExplored, 74); + const migrated = legacyResume.checkpoint.state.sharedObservability; + assertSharedObservabilityCheckpointIntegrity(migrated); + for (const [category, reason] of [ + ["authoredCoverage", "authored_knot"], + ["visibleOutcomes", "visible_outcome"], + ["terminalVariants", "terminal_variant"], + ]) { + assert.ok(migrated.timing[category].identities > 0); + assert.strictEqual(migrated.timing[category].firstAtState, null); + assert.strictEqual(migrated.timing[category].lastAtState, null); + assert.ok(migrated.samples.every((sample) => !sample.reasons.includes(reason))); + } + const secondLegacyResume = exploreSharedResumable( + compiled.storyJson, + knots, + [], + { ...options, maxStates: 100 }, + structuredClone(legacyResume.checkpoint) + ); + assert.strictEqual(secondLegacyResume.result.statesExplored, 100); + assertSharedObservabilityCheckpointIntegrity( + secondLegacyResume.checkpoint.state.sharedObservability + ); assert.throws( () => exploreSharedResumable(compiled.storyJson, knots, [], { ...options, assertions: [{}] }), /only the base shared strategy/