From 1cfe6f1c20a2bd3105b624c6080466dccaca774a Mon Sep 17 00:00:00 2001 From: Daniel Strader Date: Mon, 24 Aug 2026 19:08:11 -0700 Subject: [PATCH] Account for checkpoint reopen and report enrichment --- CHANGELOG.md | 3 +- docs/evidence-ndjson.md | 4 +- docs/local-artifacts.md | 12 +- docs/local-checkpoints.md | 6 +- docs/report-schema-v1.md | 8 +- docs/shared-checkpoint-schema-v1.md | 2 + docs/shared-search-observability.md | 4 +- src/artifacts.ts | 121 +++++- src/checkpoints.ts | 410 +++++++++++++++--- src/cli.ts | 66 ++- src/report-contract.ts | 337 ++++++++++++++- test/checkpoint-read-accounting.test.js | 480 ++++++++++++++++++++++ test/inkcheck.test.js | 99 ++++- test/report-enrichment-accounting.test.js | 393 ++++++++++++++++++ 14 files changed, 1838 insertions(+), 107 deletions(-) create mode 100644 test/checkpoint-read-accounting.test.js create mode 100644 test/report-enrichment-accounting.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab7ef4..099f940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## Unreleased -- Add versioned retained-owner and finalization receipts without changing search policy or the checksum-bound observability-v1/v2 shapes. Shared pass telemetry now accounts for the bounded observability ledger through a non-recursive canonical UTF-8 projection and preserves its known high-water state across resume; older checkpoints remain readable and explicitly report incomplete owner history. Checkpoint saves report logical graph bytes, streamed artifact/chunk bytes, compressed output, configured compression capacity, durable payload/manifest bytes, and whether encode/compress work was applied or an existing stable ID was reused. Report saves similarly expose canonical identity-string, artifact-serialization, conservative potential string, and durable bytes outside the content-derived report. Configured finalization headroom is labeled unallocated capacity and remains excluded from retained/process totals. Stable-ID algorithms are unchanged: deterministic owner state participates in newly emitted checkpoint content, while post-write receipts never feed back into the IDs or payloads they measure. These fields are observational/accounting inputs only: they do not emit the reserved checkpoint/epoch/pressure reasons, change frontier order, activate a budget or stopping policy, add checkpoint v2/readback ownership, or publish a release; #156 and #216 remain open. +- Extend the partial owner ledger across checkpoint reopen and report enrichment/finalization without adding another payload read, decode, parse, or stable-ID traversal. Successful checkpoint opens and resumes expose bounded stage-by-stage receipts, while typed corrupt/unsupported/resource-limit failures retain the work reached before failure. The opt-in accounted report builder measures the source exploration graph, finding-identity strings already materialized by enrichment, and the returned report; report saves separately distinguish created envelope graphs from reuse-parsed artifact graphs. These compact-JSON serialized-view proxies and their conservative potential sums are not exclusive retained-owner bytes or observed heap peaks. Runtime receipts remain outside saved report/checkpoint payloads and IDs, and bounded JSON streaming still avoids the monolithic report graph. This adds no checkpoint v2/framed readback, allocation, eviction, compaction, stopping policy, benchmark restart, or release; #156 and #216 remain open. +- Add versioned retained-owner and finalization receipts without changing search policy or the checksum-bound observability-v1/v2 shapes. Shared pass telemetry now accounts for the bounded observability ledger through a non-recursive canonical UTF-8 projection and preserves its known high-water state across resume; older checkpoints remain readable and explicitly report incomplete owner history. Checkpoint saves report logical graph bytes, streamed artifact/chunk bytes, compressed output, configured compression capacity, durable payload/manifest bytes, and whether encode/compress work was applied or an existing stable ID was reused. Report saves similarly expose canonical identity-string, artifact-serialization, conservative potential string, and durable bytes outside the content-derived report. Configured finalization headroom is labeled unallocated capacity and remains excluded from retained/process totals. Stable-ID algorithms are unchanged: deterministic owner state participates in newly emitted checkpoint content, while post-write receipts never feed back into the IDs or payloads they measure. These fields are observational/accounting inputs only: they do not emit the reserved checkpoint/epoch/pressure reasons, change frontier order, activate a budget or stopping policy, add checkpoint v2, or publish a release; #156 and #216 remain open. - 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. diff --git a/docs/evidence-ndjson.md b/docs/evidence-ndjson.md index 7c6960b..62e1c22 100644 --- a/docs/evidence-ndjson.md +++ b/docs/evidence-ndjson.md @@ -8,7 +8,9 @@ Current events use `schemaVersion: 1`: - `ending` records a stable event ID, global `elapsedMs`, pass-local `firstDiscoveredAtState`, numeric `choiceIndices`, and optional `foundBy` pass. It omits choice prose, final text, and variables. - `runtime_error` records the same replay coordinates plus the error message and optional source location. - `benchmark_signal` is reserved for InkBench's oracle-neutral fixtures. A root `INKBENCH_SIGNAL_MODE` tag suppresses ordinary finding records, and an exact `INKBENCH_SIGNAL:` tag emits only that numeric signal, its numeric replay path, and timing. No story prose, variables, or arbitrary tags are copied. -- `run_end` is the authoritative bounded summary. It records compile status, states explored, finding counts, limits, execution mode, truncation causes, resource envelopes/deadlines, and emitted-evidence counts. Its versioned `resources.ownerAccounting` labels configured finalization memory/time reserve as unallocated capacity, carries at most eight deterministic numeric shared-pass retained-owner summaries, and includes a post-write checkpoint receipt when checkpoint persistence occurred. Report-save accounting is carried by the ordinary JSON `artifact` reference because `--json-stream` and `--save-report` are mutually exclusive. It does not contain the full ending, pass, schedule, or discovery-curve arrays. +- `run_end` is the authoritative bounded summary. It records compile status, states explored, finding counts, limits, execution mode, truncation causes, resource envelopes/deadlines, and emitted-evidence counts. Its versioned `resources.ownerAccounting` labels configured finalization memory/time reserve as unallocated capacity, carries at most eight deterministic numeric shared-pass retained-owner summaries, includes checkpoint-reopen accounting on a resumed run, and includes a post-write checkpoint receipt when checkpoint persistence occurred. The bounded stream deliberately has no report-enrichment or report-finalization receipt because it neither constructs the monolithic report graph nor permits `--save-report`. It does not contain the full ending, pass, schedule, or discovery-curve arrays. + +Successful ordinary `--json` output adds the same bounded `resources.ownerAccounting` wrapper only after the canonical report has been built. It may include checkpoint-read, report-enrichment, checkpoint-commit, and report-finalization receipts as those lifecycle stages apply. The wrapper and the `artifact` reference remain outside the report passed to the artifact writer, so neither the saved report payload nor its stable ID contains these receipts. The report-enrichment values are compact-JSON serialized-view proxies and a conservative potential sum; shared backing can appear in both the source and returned projections, so they are not exclusive retained-owner bytes or an observed heap peak. Events are flushed as newline-delimited records during the run. A consumer may retain and replay complete finding lines even if an outer process guard later interrupts the CLI. Absence of `run_end` means the run was interrupted; it must not be relabeled as clean completion. diff --git a/docs/local-artifacts.md b/docs/local-artifacts.md index 73b4e04..fb5466d 100644 --- a/docs/local-artifacts.md +++ b/docs/local-artifacts.md @@ -6,11 +6,19 @@ Inkcheck can persist a completed CLI report without uploading story source: inkcheck story.ink --save-report --json ``` -Saving is explicit. Without `--save-report`, Inkcheck creates no report artifact and its existing stdout contract is unchanged. With the flag, the JSON report adds an `artifact` reference and stderr confirms the same stable ID. The reference also carries a schema-v1 finalization `accounting` receipt: canonical report-ID input bytes, whether artifact serialization was applied, pretty-printed artifact UTF-8 bytes, reuse readback and validation-identity bytes, a conservative total for those strings, zero current string ownership after return, and durable file bytes. The report is stored under `.inkcheck/reports/report-.json` using a same-directory temporary file and atomic rename. +Saving is explicit. Without `--save-report`, Inkcheck creates no report artifact, emits no artifact reference, and prints no saved-artifact confirmation. Successful monolithic `--json` output may still add the numeric `resources.ownerAccounting.reportEnrichment` lifecycle receipt outside the report payload; bounded `--json-stream` deliberately does not build that full enriched graph. With `--save-report`, the JSON report additionally carries an `artifact` reference and stderr confirms the same stable ID. The reference carries a schema-v1 finalization `accounting` receipt. It reports canonical report-ID input bytes, whether artifact serialization was applied, pretty-printed artifact UTF-8 bytes, reuse readback and validation-identity bytes, a conservative total for those strings, zero current string ownership after return, durable file bytes, and separately discriminated graph-materialization proxies. The report is stored under `.inkcheck/reports/report-.json` using a same-directory temporary file and atomic rename. The stable ID is derived from canonical report content plus its project-relative entrypoint binding. Repeating the same deterministic check on the same entrypoint reuses the same artifact instead of creating timestamp duplicates; identical reports from two different files cannot alias each other. Each versioned envelope records its creation time, Inkcheck and report schema versions, project-relative entrypoint, source fingerprint, effective configuration, and complete report. -The accounting receipt is deliberately outside the saved report and its content-derived ID. A reuse materializes the caller's schema-v1 canonical identity, reads the existing artifact into one raw UTF-8 string, and materializes a second canonical identity while validating the parsed report. It does not create a new pretty-printed artifact string: `serialization.status` is `not_applied` while `readback.status` is `applied`. `readback.rawArtifactString` reports that one string's count and logical UTF-8 bytes, and `readback.validationIdentity` reports the second canonical identity's count and bytes. A created artifact has `readback.status: "not_applied"` and zero-valued readback owners. The receipt derives readback bytes from the same validation read rather than reading or serializing the artifact again. Its conservative string total includes both identities and the raw artifact string; it does not claim an observed V8 heap peak or prove when garbage collection reclaimed an earlier string. +The accounting receipt is deliberately outside the saved report and its content-derived ID. Full report lifecycles may attach `reportEnrichment.status: "applied"`, containing numeric schema-v1 accounting for the source `ExploreResult` compact-JSON view, stable finding-ID input strings materialized during enrichment, the returned report compact-JSON view, their conservative potential sum, and the returned view still owned by the caller. Legacy callers that did not opt into the accounted builder receive the explicit `{ status: "not_applied", reason: "unavailable" }` variant; absence is not reported as a measured zero. The saver projects the numeric whitelist rather than copying arbitrary caller keys, so accounting does not duplicate authored prose, witness text, variables, or finding messages. + +`graphMaterialization.createdEnvelope` accounts the compact-JSON view of the artifact envelope constructed by an accounted create. On an accounted reuse, `graphMaterialization.reuseParsedArtifact` instead accounts the artifact graph returned by the existing validation parse. Exactly one graph is `applied`; the other is `not_applied`. A legacy direct save without an enrichment receipt performs no new graph walk on either create or reuse and reports the outcome graph and `conservativePotential` as `not_applied`/`unavailable` rather than inventing zero bytes or changing pre-accounting behavior. Applied conservative potential stays separate from string materialization totals, and current saver-owned graph bytes are zero after the API returns. These deterministic serialized-view proxies are not observed heap/RSS peaks, exclusive retained-owner bytes, or proof of reclamation. + +A reuse materializes the caller's schema-v1 canonical identity, reads the existing artifact into one raw UTF-8 string, and materializes a second canonical identity while validating the parsed report. It does not create a new pretty-printed artifact string: `serialization.status` is `not_applied` while `readback.status` is `applied`. `readback.rawArtifactString` reports that one string's count and logical UTF-8 bytes, and `readback.validationIdentity` reports the second canonical identity's count and bytes. A created artifact has `readback.status: "not_applied"` and zero-valued readback string owners. When reuse graph accounting is applied, its proxy is derived from that same read and parse rather than reading, parsing, or serializing the artifact again. Its conservative string total includes both identities and the raw artifact string; it does not include either graph proxy. + +Graph byte counting streams JSON tokens without materializing a whole compact artifact string and accepts Inkcheck's schema-owned plain JSON containers only. It fails closed on cycles, `BigInt`, boxed/exotic or proxy containers, accessors, and inherited or own `toJSON` hooks rather than invoking active user code. Shared subgraphs are serialized once per reference, just as JSON would serialize them, so source-plus-returned potential deliberately double-counts shared DAG backing under a conservative no-sharing assumption. Do not add these proxies to shared-search retained-current or peak totals: charge-once backing attribution remains partial under issue #216. + +MCP search-session persistence deliberately remains on the legacy unaccounted builder/saver path in this slice. That keeps enrichment and artifact graph walks out of memory-guard sampling before checkpoint suppression and `bindingLimit` decisions; session reports, IDs, and policy behavior therefore remain unchanged. The exposed accounted report lifecycle is opt-in, and its receipt remains outside the saved payload. ## Reopening safely diff --git a/docs/local-checkpoints.md b/docs/local-checkpoints.md index f9a4d33..940b388 100644 --- a/docs/local-checkpoints.md +++ b/docs/local-checkpoints.md @@ -46,6 +46,10 @@ Library callers can distinguish three `CheckpointReadError.kind` values instead `listCheckpointArtifacts`, `openCheckpointArtifact`, and `loadCheckpointForResume` accept optional `maxStoredBytes` and `maxDecompressedBytes` read limits. Defaults cap stored input at 512 MiB and schema-v1 decompression at the smaller of 512 MiB and the runtime's maximum string length. Listing uses those limits only for a sidecar-free compatibility fallback. +Successful `openCheckpointArtifact` and `loadCheckpointForResume` calls also return an additive `CheckpointReadAccountingV1` receipt. Typed failures attach the partial receipt reached before failure as `CheckpointReadError.accounting`; constructing it does not retry the failed operation. The receipt records effective configured limits (which are bounds, not allocated capacity), the existing manifest buffer/string/parsed source graph and canonical checksum-validation string when present, the one stored payload buffer, the gzip output buffer or a legacy-JSON `not_applied` marker, the one raw artifact string, the parsed artifact source graph, and the exact logical checkpoint bytes, largest source chunk, and two configuration-comparison strings from the existing stable-ID/envelope validation. Plain legacy JSON and gzip therefore have distinct truthful owner shapes without changing their accepted bytes. + +`conservativePotential.totalBytes` sums the reported logical owners that were reached plus the largest validation chunk. It is deliberately not an observed simultaneous peak, V8 heap measurement, or claim about unreported runtime/library internals such as fixed race probes, hashing state, gzip/JSON-parser workspace, or source-freshness compilation. The parsed artifact already owns its nested checkpoint; `currentReturnedGraph` transfers that same graph rather than charging it twice. It is zero after metadata-only `openCheckpointArtifact` and one with the exact logical checkpoint bytes after a successful `loadCheckpointForResume`. A stale or path-changed resume retains its existing plain source-binding error after the artifact read completed, so it is outside the typed read-failure receipt contract; callers that need accounting alongside stale/path-changed metadata can use `openCheckpointArtifact` first. + ## Atomicity and retention New checkpoints live under `.inkcheck/checkpoints/checkpoint-.json.gz`, with the bounded metadata sidecar beside them. Their stable ID derives from the exact logical checkpoint content plus the project-relative entrypoint, not from compression or manifest bytes, so repeating the same deterministic boundary reuses one artifact. Existing schema-v1 `.json` artifacts remain readable and resumable. @@ -54,7 +58,7 @@ The writer emits compact JSON through gzip directly into a private same-director `saveCheckpointArtifact` returns an additive schema-v1 `accounting` receipt outside the checkpoint object and stable ID. It reports the logical checkpoint graph's canonical UTF-8 bytes and largest source chunk from the existing ID traversal; whether artifact serialization/compression was applied; streamed artifact bytes, largest compressed output chunk, and configured zlib/stream-buffer capacity when applied; and the durable payload, manifest, and pair bytes. Configured compression capacity is explicitly not observed heap. A fast same-ID reuse reports serialization/compression as `not_applied` with zero transient counters, while a concurrent loser that encoded a candidate before finding the winner truthfully reports applied work with outcome `reused`. Neither path opens or decodes the payload an extra time merely to construct this receipt. -The receipt cannot be embedded in the checkpoint bytes it measures without becoming self-referential. It is post-write lifecycle evidence only; it does not change the stable ID, payload, manifest, retention order, or search policy. Readback/decompression/string/parse ownership remains part of checkpoint v2 follow-up work. +Neither lifecycle receipt can be embedded in the checkpoint bytes it measures without becoming self-referential. The write receipt is post-write evidence, while the read receipt is returned only by the library reopen APIs or a typed read error. They do not change the stable ID, payload, manifest, retention order, source-freshness behavior, or search policy. The read receipt reuses the existing manifest/payload read, payload digest, gzip decode, JSON parse, and stable-ID traversal; it does not add another payload read, decode, hash, parse, or graph serialization. Framed incremental readback and removal of the single-value overlap remain checkpoint-v2 work. Before exposing payload bytes, Inkcheck reserves one of 32 fixed, hidden recovery-manifest slots for that stable ID, writes the canonical at-most-64-KiB manifest there, flushes it, and directory-syncs the slot. A same-directory hard-link create is then the exclusive, no-clobber same-ID payload commit point on supported local filesystems, including ordinary NTFS/APFS/ext filesystems: concurrent losers reopen the winning bytes instead of overwriting them. The writer hashes the visible payload with a fixed-size buffer, selects only a recovery record whose size, digest, ID, and requested checkpoint summary match, and promotes that record to the canonical sidecar. Recovery therefore does not need to inflate or parse an artifact that exceeds the schema-v1 readback ceiling. diff --git a/docs/report-schema-v1.md b/docs/report-schema-v1.md index 1d9e704..a354370 100644 --- a/docs/report-schema-v1.md +++ b/docs/report-schema-v1.md @@ -15,7 +15,13 @@ Successful JSON checks contain: Compile-failure reports use the same schema/version fields and fingerprint the entry source because no compiled story exists. -When the explicit CLI `--save-report` option is used, emitted JSON additionally contains `artifact: { id, path, accounting }`. The versioned accounting receipt describes post-ID report serialization, reuse readback, validation, and durable bytes outside the saved report. On reuse, `serialization.status` is `not_applied`, while `readback.status` is `applied`; `readback.rawArtifactString` accounts the single UTF-8 validation read and `readback.validationIdentity` accounts the second canonical identity materialized from the parsed report after the caller identity was computed. A created artifact reports both readback owners as zero and `not_applied`. The saved report payload itself remains the ordinary schema-v1 envelope, avoiding a circular content-derived ID. See [local report artifacts](local-artifacts.md) for freshness and compatibility semantics. +When the explicit CLI `--save-report` option is used, emitted JSON additionally contains `artifact: { id, path, accounting }`. The versioned accounting receipt describes report enrichment, post-ID serialization, graph materialization, reuse readback and validation, and durable bytes outside the saved report. `reportEnrichment` is either the applied numeric schema-v1 receipt or the explicit `not_applied`/`unavailable` legacy variant. Applied enrichment separates the source `ExploreResult` compact-JSON proxy, stable finding-ID input strings, the returned enriched-report proxy, a conservative source-plus-returned potential, and the current returned proxy. + +Finalization independently discriminates `graphMaterialization.createdEnvelope` from `graphMaterialization.reuseParsedArtifact`. Exactly one applies for an accounted create or reuse. A legacy direct save without an enrichment receipt performs no new graph walk and instead reports the outcome graph and `conservativePotential` as explicit `not_applied`/`unavailable` variants, preserving existing behavior without inventing a zero measurement. An accounted reuse derives its graph from the existing validation parse. On reuse, `serialization.status` is `not_applied`, while `readback.status` is `applied`; `readback.rawArtifactString` accounts the single UTF-8 validation read and `readback.validationIdentity` accounts the second canonical identity materialized from the parsed report after the caller identity was computed. A created artifact reports both readback string owners as zero and `not_applied`. Graph proxies never enter the conservative string total and are not labeled as observed peaks. The saved report payload itself remains the ordinary schema-v1 envelope, avoiding a circular content-derived ID. + +All graph values are numeric compact-JSON serialized-view proxies, not exclusive retained-owner memory. They stream schema-owned plain JSON, fail closed instead of invoking proxies, accessors, or inherited/own `toJSON`, and deliberately serialize repeated DAG references repeatedly. Source plus returned therefore conservatively double-counts shared backing. These values must not be added to shared-search retained-current or peak totals; #216 charge-once owner attribution remains partial. See [local report artifacts](local-artifacts.md) for lifecycle, privacy, freshness, and compatibility semantics. + +MCP search-session persistence remains unaccounted in this slice so graph traversal allocations cannot affect its later memory-guard sample, checkpoint suppression, `bindingLimit`, or durable session state. Its ordinary report payload and stable IDs remain unchanged. Saved-finding collection responses are a separate bounded view over an immutable report. They contain `artifact`, privacy-minimal `findings`, and `page: { limit, returned, total, nextCursor }`. Fetch-by-ID returns one `summary` plus its complete `finding`; replay returns the summary plus a normal playtest result. Response pagination never changes or implies exploration coverage. diff --git a/docs/shared-checkpoint-schema-v1.md b/docs/shared-checkpoint-schema-v1.md index 3bd14ff..b0c97cf 100644 --- a/docs/shared-checkpoint-schema-v1.md +++ b/docs/shared-checkpoint-schema-v1.md @@ -54,6 +54,8 @@ The logical checkpoint remains schema v1 JSON. New local artifacts stream that J 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. +The library reopen APIs return a separate schema-v1 read-accounting receipt outside this envelope; typed `CheckpointReadError` failures carry the partial receipt reached before failure. It distinguishes the manifest and its checksum-validation string, stored payload, gzip output versus legacy JSON, raw string, parsed source graph, existing stable-ID traversal and configuration-comparison strings, effective limits, conservative potential, and checkpoint graph retained on return. Metadata-only open retains no checkpoint graph; successful resume transfers one. Conservative potential sums the reported reached logical owners and the traversal's largest temporary source chunk, not an observed peak or full V8/runtime heap. These counters are derived from the existing single read/decode/parse/digest/ID-validation path, so they change neither bytes, stable identity, validation order, source-freshness behavior, nor split-run trajectory. + ## 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 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. diff --git a/docs/shared-search-observability.md b/docs/shared-search-observability.md index f65b442..b035c58 100644 --- a/docs/shared-search-observability.md +++ b/docs/shared-search-observability.md @@ -64,7 +64,9 @@ These are deterministic logical estimates compatible with the existing `sharedMe Fresh runs report complete owner history. A new checkpoint persists only the versioned owner high-water state beside—not inside—the checksum-bound observability ledger. Reopening an older checkpoint derives the current ledger footprint but reports `historyComplete: false` instead of inventing historical peaks. Checkpoint construction remains side-effect-free, and this sibling state does not change frontier policy. -Owner attribution remains incomplete. The write receipts described below cover checkpoint encoding/compression capacity and report serialization, but retained samples still do not account for Ink runtime objects, checkpoint decoding/reopen buffers, report merge/enrichment graphs, process-tree memory, or every transient combined heap peak. Configured checkpoint/finalization headroom is capacity, not retained allocation, and is never added to logical current/peak totals. A finalization time reserve is effective only inside an overall time envelope; without `maxTimeMs`, its reported effective `timeMs` is zero because no deadline exists from which time can be withheld. These values must not be described as total search-owned memory, and this slice does not set owner budgets or apply pressure actions. +Owner attribution remains incomplete. Separate lifecycle receipts now describe the existing checkpoint reopen path—manifest, stored and decompressed buffers, raw JSON, parsed graph, validation traversal, and a transfer-only returned checkpoint graph—and the existing report enrichment/save path. The report receipt measures compact-JSON serialized-view proxies for the source exploration graph, enriched returned report, created artifact envelope, or reuse-parsed artifact as applicable. Its conservative potential deliberately assumes no reclamation, but source and returned views can repeat shared backing; it is therefore neither exclusive retained-owner attribution nor an observed simultaneous heap peak. These lifecycle values are never added to shared retained `current`/`peak` totals. + +Retained samples still do not account for Ink runtime objects, process-tree memory, parser/zlib implementation internals, or every transient combined heap peak. Configured checkpoint/finalization headroom is capacity, not retained allocation, and is never added to logical current/peak totals. A finalization time reserve is effective only inside an overall time envelope; without `maxTimeMs`, its reported effective `timeMs` is zero because no deadline exists from which time can be withheld. These values must not be described as total search-owned memory, and this slice does not set owner budgets or apply pressure actions. ## Yield vector and event timing diff --git a/src/artifacts.ts b/src/artifacts.ts index b57538e..8e68a32 100644 --- a/src/artifacts.ts +++ b/src/artifacts.ts @@ -12,6 +12,11 @@ import { } from "./discovery"; export { DEFAULT_MAX_PROJECT_REPORT_BYTES, DEFAULT_MAX_REPORT_BYTES, MAX_REPORT_PRUNE_PER_RUN } from "./discovery"; import { VERSION } from "./version"; +import { + measureLogicalJsonGraphV1, + type LogicalJsonGraphV1, + type ReportEnrichmentAccountingV1, +} from "./report-contract"; export type ArtifactFreshness = "current" | "stale" | "path_changed"; @@ -49,6 +54,39 @@ export interface ReportArtifactReference extends ArtifactReference { export interface ReportFinalizationAccountingV1 { schemaVersion: 1; outcome: "created" | "reused"; + reportEnrichment: { + status: "applied"; + accounting: ReportEnrichmentAccountingV1; + } | { + status: "not_applied"; + reason: "unavailable"; + }; + graphMaterialization: { + createdEnvelope: { + status: "applied"; + graph: LogicalJsonGraphV1; + } | { + status: "not_applied"; + reason: "not_created" | "unavailable"; + }; + reuseParsedArtifact: { + status: "applied"; + graph: LogicalJsonGraphV1; + } | { + status: "not_applied"; + reason: "not_reused" | "unavailable"; + }; + /** Applicable compact-JSON proxy, not an observed heap peak. */ + conservativePotential: { + status: "applied"; + logicalJsonUtf8Bytes: number; + } | { + status: "not_applied"; + reason: "unavailable"; + }; + /** The save API returns only a reference and retains neither internal graph. */ + currentLogicalJsonUtf8Bytes: 0; + }; reportIdentity: { count: 1; /** UTF-8 bytes in the canonical report-ID input materialized by schema v1. */ @@ -193,6 +231,75 @@ function exactByteSum(description: string, values: readonly number[]): number { return total; } +function requiredSafeCount(description: string, value: unknown): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new RangeError(`${description} must be a non-negative safe integer`); + } + return value as number; +} + +function reportEnrichmentReceipt( + value: ReportEnrichmentAccountingV1 | undefined +): ReportFinalizationAccountingV1["reportEnrichment"] { + if (value === undefined) return { status: "not_applied", reason: "unavailable" }; + if (value.schemaVersion !== 1 || value.sourceExploreGraph?.count !== 1 + || value.returnedReportGraph?.count !== 1) { + throw new Error("report enrichment accounting must use schema v1 with one source and returned graph"); + } + const sourceBytes = requiredSafeCount( + "report enrichment source graph byte count", + value.sourceExploreGraph.logicalJsonUtf8Bytes + ); + const returnedBytes = requiredSafeCount( + "report enrichment returned graph byte count", + value.returnedReportGraph.logicalJsonUtf8Bytes + ); + const identityCount = requiredSafeCount( + "report enrichment finding identity count", + value.findingIdentityStrings?.count + ); + const identityBytes = requiredSafeCount( + "report enrichment finding identity byte count", + value.findingIdentityStrings?.logicalUtf8Bytes + ); + const conservative = exactByteSum("report enrichment graph byte count", [sourceBytes, returnedBytes]); + if (value.graphOwnership?.conservativePotentialLogicalJsonUtf8Bytes !== conservative + || value.graphOwnership.currentReturnedLogicalJsonUtf8Bytes !== returnedBytes) { + throw new Error("report enrichment accounting graph ownership totals are inconsistent"); + } + return { + status: "applied", + accounting: { + schemaVersion: 1, + sourceExploreGraph: { count: 1, logicalJsonUtf8Bytes: sourceBytes }, + findingIdentityStrings: { count: identityCount, logicalUtf8Bytes: identityBytes }, + returnedReportGraph: { count: 1, logicalJsonUtf8Bytes: returnedBytes }, + graphOwnership: { + conservativePotentialLogicalJsonUtf8Bytes: conservative, + currentReturnedLogicalJsonUtf8Bytes: returnedBytes, + }, + }, + }; +} + +function graphMaterializationReceipt( + outcome: "created" | "reused", + graph?: LogicalJsonGraphV1 +): ReportFinalizationAccountingV1["graphMaterialization"] { + return { + createdEnvelope: outcome === "created" && graph + ? { status: "applied", graph } + : { status: "not_applied", reason: outcome === "created" ? "unavailable" : "not_created" }, + reuseParsedArtifact: outcome === "reused" && graph + ? { status: "applied", graph } + : { status: "not_applied", reason: outcome === "reused" ? "unavailable" : "not_reused" }, + conservativePotential: graph + ? { status: "applied", logicalJsonUtf8Bytes: graph.logicalJsonUtf8Bytes } + : { status: "not_applied", reason: "unavailable" }, + currentLogicalJsonUtf8Bytes: 0, + }; +} + function reportsDirectory(projectRoot: string): string { return path.join(path.resolve(projectRoot), ".inkcheck", "reports"); } @@ -443,7 +550,8 @@ export function saveReportArtifact( projectRoot: string, entrypoint: string, report: Record, - inputLimits: ReportStorageLimits = {} + inputLimits: ReportStorageLimits = {}, + reportEnrichmentAccounting?: ReportEnrichmentAccountingV1 ): ReportArtifactReference { const root = path.resolve(projectRoot); const absoluteSource = path.resolve(entrypoint); @@ -454,12 +562,16 @@ export function saveReportArtifact( const directory = reportsDirectory(root); const destination = artifactFile(root, id); const limits = reportStorageLimits(inputLimits); + const enrichmentReceipt = reportEnrichmentReceipt(reportEnrichmentAccounting); fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); if (process.platform !== "win32") fs.chmodSync(directory, 0o700); if (fs.existsSync(destination)) { const rawArtifact = fs.readFileSync(destination, "utf8"); const rawArtifactUtf8Bytes = Buffer.byteLength(rawArtifact, "utf8"); const parsed = parseArtifactDetailed(rawArtifact, id); + const reuseParsedArtifactGraph = reportEnrichmentAccounting === undefined + ? undefined + : measureLogicalJsonGraphV1(parsed.artifact); if (process.platform !== "win32") fs.chmodSync(destination, 0o600); const artifactBytes = fs.statSync(destination).size; return { @@ -468,6 +580,8 @@ export function saveReportArtifact( accounting: { schemaVersion: 1, outcome: "reused", + reportEnrichment: enrichmentReceipt, + graphMaterialization: graphMaterializationReceipt("reused", reuseParsedArtifactGraph), reportIdentity: { count: 1, logicalUtf8Bytes: identity.logicalUtf8Bytes }, serialization: { status: "not_applied", @@ -504,6 +618,9 @@ export function saveReportArtifact( effectiveConfiguration: report.effectiveConfiguration, report, }; + const createdEnvelopeGraph = reportEnrichmentAccounting === undefined + ? undefined + : measureLogicalJsonGraphV1(artifact); const serialized = `${JSON.stringify(artifact, null, 2)}\n`; const bytes = Buffer.byteLength(serialized, "utf8"); if (bytes > limits.maxReportBytes) { @@ -533,6 +650,8 @@ export function saveReportArtifact( accounting: { schemaVersion: 1, outcome: "created", + reportEnrichment: enrichmentReceipt, + graphMaterialization: graphMaterializationReceipt("created", createdEnvelopeGraph), reportIdentity: { count: 1, logicalUtf8Bytes: identity.logicalUtf8Bytes }, serialization: { status: "applied", diff --git a/src/checkpoints.ts b/src/checkpoints.ts index ec7881d..735bc3f 100644 --- a/src/checkpoints.ts +++ b/src/checkpoints.ts @@ -110,11 +110,84 @@ export interface CheckpointReadLimits { maxDecompressedBytes?: number; } +export interface CheckpointReadAccountingV1 { + schemaVersion: 1; + status: "completed" | "failed"; + storageEncoding: "json" | "gzip"; + /** Effective safety limits. These values describe bounds, not allocations. */ + configuredLimits: { + basis: "configured_limits_not_allocated_capacity"; + maxManifestBytes: number; + maxStoredBytes: number; + maxDecompressedBytes: number; + }; + manifest: { + status: "applied" | "not_present" | "not_completed"; + storedBuffer: { count: 0 | 1; bytes: number }; + rawString: { count: 0 | 1; logicalUtf8Bytes: number }; + parsedGraph: { count: 0 | 1; sourceLogicalUtf8Bytes: number }; + validationString: { count: 0 | 1; logicalUtf8Bytes: number }; + }; + storedPayload: { + status: "applied" | "not_completed"; + count: 0 | 1; + bytes: number; + }; + decompressedPayload: { + status: "applied" | "not_applied" | "not_completed"; + count: 0 | 1; + bytes: number; + }; + rawArtifactString: { + status: "applied" | "not_completed"; + count: 0 | 1; + logicalUtf8Bytes: number; + }; + parsedArtifactGraph: { + status: "applied" | "not_completed"; + count: 0 | 1; + /** UTF-8 bytes in the JSON source from which this graph was parsed. */ + sourceLogicalUtf8Bytes: number; + }; + validationTraversal: { + status: "applied" | "not_completed"; + count: 0 | 1; + logicalCheckpointUtf8BytesVisited: number; + peakSourceChunkUtf8Bytes: number; + configurationStrings: { + count: 0 | 2; + logicalUtf8Bytes: number; + peakStringUtf8Bytes: number; + }; + }; + /** + * Sum of every reached logical owner reported by this receipt plus the + * largest validation chunk. This is conservative potential, not an observed + * simultaneous peak or a measurement of runtime/library internals. + */ + conservativePotential: { + basis: "sum_of_reached_logical_owners_not_observed_peak"; + totalBytes: number; + }; + /** The checkpoint graph retained by the API caller after return. */ + currentReturnedGraph: { + status: "applied" | "not_applied"; + count: 0 | 1; + logicalUtf8Bytes: number; + }; + failure?: { + kind: CheckpointReadErrorKind; + stage: CheckpointReadStage; + }; +} + export type CheckpointReadErrorKind = "corrupt" | "resource_limit" | "unsupported"; export type CheckpointReadStage = "manifest" | "storage" | "decompression" | "json" | "envelope"; export class CheckpointReadError extends Error { payloadVerified = false; + /** Partial read-accounting receipt when the error came from open/resume. */ + accounting?: CheckpointReadAccountingV1; constructor( public readonly kind: CheckpointReadErrorKind, @@ -171,6 +244,7 @@ interface LoadedCheckpointArtifact { storageEncoding: "json" | "gzip"; payloadSizeBytes: number; payloadSha256: string; + accounting: CheckpointReadAccountingV1; } interface StoredCheckpointManifest { @@ -386,6 +460,98 @@ function exactByteSum(description: string, values: readonly number[]): number { return total; } +function checkpointReadAccounting( + storageEncoding: "json" | "gzip", + limits: Required +): CheckpointReadAccountingV1 { + return { + schemaVersion: 1, + status: "completed", + storageEncoding, + configuredLimits: { + basis: "configured_limits_not_allocated_capacity", + maxManifestBytes: MAX_CHECKPOINT_MANIFEST_BYTES, + maxStoredBytes: limits.maxStoredBytes, + maxDecompressedBytes: limits.maxDecompressedBytes, + }, + manifest: { + status: "not_present", + storedBuffer: { count: 0, bytes: 0 }, + rawString: { count: 0, logicalUtf8Bytes: 0 }, + parsedGraph: { count: 0, sourceLogicalUtf8Bytes: 0 }, + validationString: { count: 0, logicalUtf8Bytes: 0 }, + }, + storedPayload: { status: "not_completed", count: 0, bytes: 0 }, + decompressedPayload: storageEncoding === "gzip" + ? { status: "not_completed", count: 0, bytes: 0 } + : { status: "not_applied", count: 0, bytes: 0 }, + rawArtifactString: { + status: "not_completed", + count: 0, + logicalUtf8Bytes: 0, + }, + parsedArtifactGraph: { + status: "not_completed", + count: 0, + sourceLogicalUtf8Bytes: 0, + }, + validationTraversal: { + status: "not_completed", + count: 0, + logicalCheckpointUtf8BytesVisited: 0, + peakSourceChunkUtf8Bytes: 0, + configurationStrings: { + count: 0, + logicalUtf8Bytes: 0, + peakStringUtf8Bytes: 0, + }, + }, + conservativePotential: { + basis: "sum_of_reached_logical_owners_not_observed_peak", + totalBytes: 0, + }, + currentReturnedGraph: { + status: "not_applied", + count: 0, + logicalUtf8Bytes: 0, + }, + }; +} + +function finalizeCheckpointReadAccounting( + accounting: CheckpointReadAccountingV1, + error?: CheckpointReadError +): CheckpointReadAccountingV1 { + accounting.status = error ? "failed" : "completed"; + if (error) accounting.failure = { kind: error.kind, stage: error.stage }; + else delete accounting.failure; + accounting.conservativePotential.totalBytes = exactByteSum( + "checkpoint read conservative-potential byte count", + [ + accounting.manifest.storedBuffer.bytes, + accounting.manifest.rawString.logicalUtf8Bytes, + accounting.manifest.parsedGraph.sourceLogicalUtf8Bytes, + accounting.manifest.validationString.logicalUtf8Bytes, + accounting.storedPayload.bytes, + accounting.decompressedPayload.bytes, + accounting.rawArtifactString.logicalUtf8Bytes, + accounting.parsedArtifactGraph.sourceLogicalUtf8Bytes, + accounting.validationTraversal.peakSourceChunkUtf8Bytes, + accounting.validationTraversal.configurationStrings.logicalUtf8Bytes, + ] + ); + return accounting; +} + +function retainReturnedCheckpointGraph(accounting: CheckpointReadAccountingV1): void { + accounting.currentReturnedGraph = { + status: "applied", + count: 1, + logicalUtf8Bytes: accounting.validationTraversal.logicalCheckpointUtf8BytesVisited, + }; + finalizeCheckpointReadAccounting(accounting); +} + function accountJsonChunk(accounting: JsonChunkAccounting, chunk: string): void { const bytes = Buffer.byteLength(chunk); accounting.logicalUtf8Bytes = exactByteAdd( @@ -473,7 +639,8 @@ function readBoundedBuffer( file: string, limitBytes: number, stage: CheckpointReadStage, - description: string + description: string, + onBufferAllocated?: (bytes: number) => void ): Buffer { const fd = fs.openSync(file, "r"); try { @@ -491,6 +658,7 @@ function readBoundedBuffer( ); } const value = Buffer.allocUnsafe(size); + onBufferAllocated?.(size); let offset = 0; while (offset < size) { const read = fs.readSync(fd, value, offset, size - offset, offset); @@ -509,7 +677,8 @@ function readBoundedBuffer( function readLegacyJsonBuffer( file: string, - limits: Required + limits: Required, + onBufferAllocated?: (bytes: number) => void ): Buffer { const fd = fs.openSync(file, "r"); try { @@ -535,6 +704,7 @@ function readLegacyJsonBuffer( ); } const value = Buffer.allocUnsafe(size); + onBufferAllocated?.(size); let offset = 0; while (offset < size) { const read = fs.readSync(fd, value, offset, size - offset, offset); @@ -571,7 +741,11 @@ function readLegacyJsonBuffer( } } -function decompressCheckpointJson(compressed: Buffer, limits: Required): string { +function decompressCheckpointJson( + compressed: Buffer, + limits: Required, + accounting?: CheckpointReadAccountingV1 +): string { let decompressed: Buffer; try { decompressed = gunzipSync(compressed, { maxOutputLength: limits.maxDecompressedBytes }); @@ -586,6 +760,13 @@ function decompressCheckpointJson(compressed: Buffer, limits: Required { + accounting.storedPayload = { status: "not_completed", count: 1, bytes }; + } + ); + accounting.storedPayload.status = "applied"; + payloadSizeBytes = stored.length; + payloadSha256 = createHash("sha256").update(stored).digest("hex"); + if (storedManifest && storedManifest.artifactSha256 !== payloadSha256) { + throw corrupt("storage", "checkpoint artifact bytes do not match its metadata checksum; restore or regenerate it"); + } + try { + raw = decompressCheckpointJson(stored, limits, accounting); + } catch (error) { + if (storedManifest && error instanceof CheckpointReadError && error.kind === "resource_limit") { + error.payloadVerified = true; + } + throw error; + } + } else { + const stored = readLegacyJsonBuffer(file, limits, (bytes) => { + accounting.storedPayload = { status: "not_completed", count: 1, bytes }; + }); + accounting.storedPayload.status = "applied"; + raw = stored.toString("utf8"); + payloadSizeBytes = stored.length; + payloadSha256 = createHash("sha256").update(stored).digest("hex"); + if (storedManifest && storedManifest.artifactSha256 !== payloadSha256) { + throw corrupt("storage", "checkpoint artifact bytes do not match its metadata checksum; restore or regenerate it"); } - throw error; } - } else { - const stored = readLegacyJsonBuffer(file, limits); - raw = stored.toString("utf8"); - payloadSizeBytes = stored.length; - payloadSha256 = createHash("sha256").update(stored).digest("hex"); - if (storedManifest && storedManifest.artifactSha256 !== payloadSha256) { - throw corrupt("storage", "checkpoint artifact bytes do not match its metadata checksum; restore or regenerate it"); + accounting.rawArtifactString = { + status: "applied", + count: 1, + logicalUtf8Bytes: Buffer.byteLength(raw, "utf8"), + }; + const artifact = parseArtifact(raw, id, accounting); + validateStoredEntrypoint(projectRoot, artifact.source.entrypoint, "envelope"); + const loaded: LoadedCheckpointArtifact = { + artifact, + file, + storageEncoding, + payloadSizeBytes, + payloadSha256, + accounting, + }; + validateManifestForLoaded(projectRoot, loaded, storedManifest); + finalizeCheckpointReadAccounting(accounting); + return loaded; + } catch (error) { + if (error instanceof CheckpointReadError) { + error.accounting = finalizeCheckpointReadAccounting(accounting, error); } + throw error; } - const artifact = parseArtifact(raw, id); - validateStoredEntrypoint(projectRoot, artifact.source.entrypoint, "envelope"); - const loaded: LoadedCheckpointArtifact = { - artifact, - file, - storageEncoding, - payloadSizeBytes, - payloadSha256, - }; - validateManifestForLoaded(projectRoot, loaded, storedManifest); - return loaded; } function summary(projectRoot: string, loaded: LoadedCheckpointArtifact): CheckpointArtifactSummary { @@ -846,22 +1093,40 @@ function manifestBody( } function manifestDigest( - manifest: Omit + manifest: Omit, + accounting?: CheckpointReadAccountingV1 ): string { - return createHash("sha256").update(JSON.stringify(manifest)).digest("hex"); + const raw = JSON.stringify(manifest); + if (accounting) { + accounting.manifest.validationString = { + count: 1, + logicalUtf8Bytes: Buffer.byteLength(raw, "utf8"), + }; + } + return createHash("sha256").update(raw).digest("hex"); } function serializedManifest(manifest: CheckpointArtifactManifest): string { return JSON.stringify(manifest); } -function parseManifest(raw: string, expectedId: string): CheckpointArtifactManifest { +function parseManifest( + raw: string, + expectedId: string, + accounting?: CheckpointReadAccountingV1 +): CheckpointArtifactManifest { let value: unknown; try { value = JSON.parse(raw); } catch { throw corrupt("manifest", "checkpoint metadata manifest is corrupt JSON; restore or regenerate it"); } + if (accounting) { + accounting.manifest.parsedGraph = { + count: 1, + sourceLogicalUtf8Bytes: Buffer.byteLength(raw, "utf8"), + }; + } if (!value || typeof value !== "object") { throw corrupt("manifest", "checkpoint metadata manifest must be a JSON object"); } @@ -905,13 +1170,17 @@ function parseManifest(raw: string, expectedId: string): CheckpointArtifactManif throw corrupt("manifest", "checkpoint metadata manifest is missing required fields; restore or regenerate it"); } const complete = manifest as CheckpointArtifactManifest; - if (manifestDigest(manifestBody(complete)) !== complete.manifestSha256) { + if (manifestDigest(manifestBody(complete), accounting) !== complete.manifestSha256) { throw corrupt("manifest", "checkpoint metadata manifest content does not match its canonical checksum"); } return complete; } -function readCheckpointManifest(projectRoot: string, id: string): { +function readCheckpointManifest( + projectRoot: string, + id: string, + accounting?: CheckpointReadAccountingV1 +): { manifest: CheckpointArtifactManifest; sizeBytes: number; } { @@ -920,9 +1189,23 @@ function readCheckpointManifest(projectRoot: string, id: string): { manifestFile, MAX_CHECKPOINT_MANIFEST_BYTES, "manifest", - "checkpoint metadata manifest" + "checkpoint metadata manifest", + (bytes) => { + if (accounting) accounting.manifest.storedBuffer = { count: 1, bytes }; + } ); - return { manifest: parseManifest(stored.toString("utf8"), id), sizeBytes: stored.length }; + const raw = stored.toString("utf8"); + if (accounting) { + accounting.manifest.rawString = { + count: 1, + logicalUtf8Bytes: Buffer.byteLength(raw, "utf8"), + }; + } + const manifest = parseManifest(raw, id, accounting); + if (accounting) { + accounting.manifest.status = "applied"; + } + return { manifest, sizeBytes: stored.length }; } function validateManifestStorage( @@ -965,7 +1248,17 @@ function validateManifestForLoaded( ): void { const manifestFile = checkpointManifestFile(projectRoot, loaded.artifact.id); if (!alreadyRead && !fs.existsSync(manifestFile)) return; - const manifest = alreadyRead ?? readCheckpointManifest(projectRoot, loaded.artifact.id).manifest; + let manifest: CheckpointArtifactManifest; + if (alreadyRead) { + manifest = alreadyRead; + } else { + loaded.accounting.manifest.status = "not_completed"; + manifest = readCheckpointManifest( + projectRoot, + loaded.artifact.id, + loaded.accounting + ).manifest; + } validateManifestStorage(projectRoot, loaded.artifact.id, loaded.file, manifest); const artifact = loaded.artifact; if (manifest.artifactSizeBytes !== loaded.payloadSizeBytes @@ -2420,10 +2713,14 @@ export async function openCheckpointArtifact( readLimits: CheckpointReadLimits = {} ): Promise<{ artifact: CheckpointArtifactSummary & { freshness: CheckpointFreshness }; + accounting: CheckpointReadAccountingV1; }> { const loaded = loadArtifactDetailed(projectRoot, id, readLimits); const current = await freshness(projectRoot, loaded.artifact); - return { artifact: { ...summary(projectRoot, loaded), freshness: current.freshness } }; + return { + artifact: { ...summary(projectRoot, loaded), freshness: current.freshness }, + accounting: loaded.accounting, + }; } export async function loadCheckpointForResume( @@ -2434,15 +2731,18 @@ export async function loadCheckpointForResume( artifact: CheckpointArtifactSummary & { freshness: "current" }; checkpoint: SharedSearchCheckpoint; entrypoint: string; + accounting: CheckpointReadAccountingV1; }> { const loaded = loadArtifactDetailed(projectRoot, id, readLimits); const current = await freshness(projectRoot, loaded.artifact); if (current.freshness !== "current") { throw new Error(`checkpoint ${id} is ${current.freshness}; resume requires the exact source and knot map used to create it`); } + retainReturnedCheckpointGraph(loaded.accounting); return { artifact: { ...summary(projectRoot, loaded), freshness: "current" }, checkpoint: loaded.artifact.checkpoint, entrypoint: current.entrypoint, + accounting: loaded.accounting, }; } diff --git a/src/cli.ts b/src/cli.ts index 6c41815..8f38076 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -49,7 +49,9 @@ import { import { buildCompileFailureEnvelope, buildReportEnvelope, + buildReportEnvelopeAccounted, EffectiveReportConfiguration, + type ReportEnrichmentAccountingV1, } from "./report-contract"; import { capabilities, @@ -863,9 +865,18 @@ async function main() { const { storyJson: _compiledStoryJson, ...compileReport } = compiled; emitProgress("phase_end", { phase: "compile" }); - const persistReport = (value: Record) => { + const persistReport = ( + value: Record, + reportEnrichmentAccounting?: ReportEnrichmentAccountingV1 + ) => { const projectRoot = artifactProjectRoot(file!, projectConfig?.path); - const reference = saveReportArtifact(projectRoot, file!, value); + const reference = saveReportArtifact( + projectRoot, + file!, + value, + {}, + reportEnrichmentAccounting + ); console.error(`saved report ${reference.id} (${reference.path})`); return reference; }; @@ -1181,7 +1192,7 @@ async function main() { // The bounded stream deliberately avoids materializing the enriched full // report: at marathon scale that duplicate object graph and its monolithic // JSON string can exceed V8's string limit after search has already ended. - const outputReport = asJsonStream ? undefined : buildReportEnvelope({ + const reportInput = { compile: compileReport, stats: st, ...(profile ? { profile } : {}), @@ -1190,7 +1201,14 @@ async function main() { ...(followNext ? { runs } : {}), storyJson: compiled.storyJson!, configuration: reportConfiguration, - }); + }; + const reportBuild = asJsonStream + ? undefined + : asJson || saveReport + ? buildReportEnvelopeAccounted(reportInput) + : { report: buildReportEnvelope(reportInput), accounting: undefined }; + const outputReport = reportBuild?.report; + const reportEnrichmentAccounting = reportBuild?.accounting; let checkpointOutput: Record | undefined; if (saveCheckpoint) { if (nextCheckpoint) { @@ -1216,16 +1234,29 @@ async function main() { : "run ended without resumable state; no checkpoint was created"); } } - const artifact = saveReport ? persistReport(outputReport!) : undefined; + const artifact = saveReport + ? persistReport(outputReport!, reportEnrichmentAccounting) + : undefined; + const sharedRetainedPasses = (report.passes ?? []) + .filter((pass) => pass.sharedOwnerAccounting !== undefined) + .slice(0, 8) + .map((pass) => ({ + pass: pass.pass, + ...pass.sharedOwnerAccounting!, + })); + const runtimeOwnerAccounting = { + schemaVersion: 1 as const, + configuredReserve: finalizationReserve, + ...(sharedRetainedPasses.length > 0 ? { sharedRetainedPasses } : {}), + ...(resumed ? { checkpointRead: resumed.accounting } : {}), + ...(reportEnrichmentAccounting ? { reportEnrichment: reportEnrichmentAccounting } : {}), + ...(checkpointOutput && "accounting" in checkpointOutput + ? { checkpointCommit: checkpointOutput.accounting } + : {}), + ...(artifact ? { reportFinalization: artifact.accounting } : {}), + }; emitProgress("phase_start", { phase: "report" }); if (asJsonStream) { - const sharedRetainedPasses = (report.passes ?? []) - .filter((pass) => pass.sharedOwnerAccounting !== undefined) - .slice(0, 8) - .map((pass) => ({ - pass: pass.pass, - ...pass.sharedOwnerAccounting!, - })); let terminalLogicalAccountedBytes = 0; for (let index = (report.passes?.length ?? 0) - 1; index >= 0; index--) { const pass = report.passes![index]; @@ -1275,15 +1306,7 @@ async function main() { deadlineMs: maxTimeSec === undefined ? null : startedAt + maxTimeSec * 1_000, searchDeadlineMs: deadlineMs ?? null, finalizationTimeReserveMs: finalizationReserve.timeMs, - ownerAccounting: { - schemaVersion: 1, - configuredReserve: finalizationReserve, - ...(sharedRetainedPasses.length > 0 ? { sharedRetainedPasses } : {}), - ...(checkpointOutput && "accounting" in checkpointOutput - ? { checkpointCommit: checkpointOutput.accounting } - : {}), - ...(artifact ? { reportFinalization: artifact.accounting } : {}), - }, + ownerAccounting: runtimeOwnerAccounting, observedProcessAtTermination: observeProcessMemory(terminalLogicalAccountedBytes), }, evidence: { endingsEmitted: streamedEndings, runtimeErrorsEmitted: streamedRuntimeErrors, benchmarkSignalsEmitted: streamedBenchmarkSignals }, @@ -1295,6 +1318,7 @@ async function main() { ...outputReport!, ...(artifact ? { artifact } : {}), ...(checkpointOutput ? { checkpoint: checkpointOutput } : {}), + resources: { ownerAccounting: runtimeOwnerAccounting }, }, null, 2 diff --git a/src/report-contract.ts b/src/report-contract.ts index fbecece..823d3d3 100644 --- a/src/report-contract.ts +++ b/src/report-contract.ts @@ -1,5 +1,6 @@ import { createHash } from "crypto"; import * as fs from "fs"; +import { types as utilTypes } from "util"; import type { CompileResult, StoryShapeProfile } from "./inklecate"; import type { ExploreResult, EndingReport, RuntimeErrorReport } from "./explore"; import type { NextRunAdvice } from "./advice"; @@ -26,6 +27,221 @@ export type FindingKind = | "goal.reached" | "ending.reached"; +/** + * A deterministic logical-size proxy for one JSON graph. The byte count is + * the exact UTF-8 size of its compact JSON representation; it is not a heap + * measurement or an observed process-memory peak. + */ +export interface LogicalJsonGraphV1 { + count: 1; + logicalJsonUtf8Bytes: number; +} + +export interface ReportEnrichmentAccountingV1 { + schemaVersion: 1; + sourceExploreGraph: LogicalJsonGraphV1; + /** Stable finding-ID input strings materialized while enriching the report. */ + findingIdentityStrings: { + count: number; + logicalUtf8Bytes: number; + }; + returnedReportGraph: LogicalJsonGraphV1; + graphOwnership: { + /** Source plus returned proxies, conservatively assuming no sharing or reclamation. */ + conservativePotentialLogicalJsonUtf8Bytes: number; + /** The builder returns one report graph and retains no source graph itself. */ + currentReturnedLogicalJsonUtf8Bytes: number; + }; +} + +function safeByteSum(description: string, values: readonly number[]): number { + let total = 0; + for (const value of values) { + if (!Number.isSafeInteger(value) || value < 0 || value > Number.MAX_SAFE_INTEGER - total) { + throw new RangeError(`${description} exceeds the safe integer range`); + } + total += value; + } + return total; +} + +const OMIT_JSON_VALUE = Symbol("omit-json-value"); + +function prepareJsonValue(value: unknown): unknown | typeof OMIT_JSON_VALUE { + if ((typeof value === "object" && value !== null) || typeof value === "function") { + const reflectable = value as object; + if (utilTypes.isProxy(reflectable)) throw new TypeError("logical JSON graph must not contain Proxy containers"); + let owner: object | null = reflectable; + while (owner !== null) { + if (utilTypes.isProxy(owner)) throw new TypeError("logical JSON graph must not contain Proxy containers"); + const toJsonDescriptor = Object.getOwnPropertyDescriptor(owner, "toJSON"); + if (toJsonDescriptor) { + if (!("value" in toJsonDescriptor) || typeof toJsonDescriptor.value === "function") { + throw new TypeError("logical JSON graph must not contain toJSON hooks"); + } + break; + } + owner = Object.getPrototypeOf(owner); + } + } + return value === undefined || typeof value === "function" || typeof value === "symbol" + ? OMIT_JSON_VALUE + : value; +} + +function plainJsonObjectKeys(value: Record): string[] { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("logical JSON graph containers must be plain objects or arrays"); + } + const keys = Object.keys(value); + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + throw new TypeError("logical JSON graph must not contain accessor properties"); + } + } + return keys; +} + +function plainJsonArrayItem(value: unknown[], index: number): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor) { + if (String(index) in value) { + throw new TypeError("logical JSON graph arrays must not inherit indexed values"); + } + return undefined; + } + if (!("value" in descriptor)) throw new TypeError("logical JSON graph must not contain accessor properties"); + return descriptor.value; +} + +function jsonStringUtf8Bytes(value: string): number { + let bytes = 2; // opening and closing quotes + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code === 0x22 || code === 0x5c) { + bytes += 2; + } else if (code < 0x20) { + bytes += code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d + ? 2 + : 6; + } else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + index++; + } else { + bytes += 6; // well-formed JSON escapes a lone high surrogate + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + bytes += 6; // well-formed JSON escapes a lone low surrogate + } else if (code <= 0x7f) { + bytes += 1; + } else if (code <= 0x7ff) { + bytes += 2; + } else { + bytes += 3; + } + } + return bytes; +} + +type JsonByteCountFrame = + | { kind: "value"; value: unknown } + | { kind: "array"; value: unknown[]; index: number; length: number } + | { kind: "object"; value: Record; keys: string[]; index: number; emitted: boolean }; + +/** + * Count compact-JSON UTF-8 bytes without materializing a whole-graph JSON + * string. Traversal retains only the active container stack and its key + * vectors; strings are counted code-unit by code-unit without an escaped copy. + */ +export function measureLogicalJsonGraphV1(value: unknown): LogicalJsonGraphV1 { + const root = prepareJsonValue(value); + if (root === OMIT_JSON_VALUE) throw new TypeError("logical JSON graph must have a serializable root value"); + let logicalJsonUtf8Bytes = 0; + const ancestors = new Set(); + const stack: JsonByteCountFrame[] = [{ kind: "value", value: root }]; + const add = (...values: number[]) => { + logicalJsonUtf8Bytes = safeByteSum("logical JSON graph byte count", [logicalJsonUtf8Bytes, ...values]); + }; + + while (stack.length > 0) { + const frame = stack.pop()!; + if (frame.kind === "array") { + if (frame.index >= frame.length) { + add(1); // ] + ancestors.delete(frame.value); + continue; + } + const index = frame.index; + frame.index++; + stack.push(frame); + if (index > 0) add(1); // , + const item = prepareJsonValue(plainJsonArrayItem(frame.value, index)); + if (item === OMIT_JSON_VALUE) add(4); // null + else stack.push({ kind: "value", value: item }); + continue; + } + if (frame.kind === "object") { + let scheduled = false; + while (frame.index < frame.keys.length) { + const key = frame.keys[frame.index++]; + const descriptor = Object.getOwnPropertyDescriptor(frame.value, key)!; + const item = prepareJsonValue(descriptor.value); + if (item === OMIT_JSON_VALUE) continue; + if (frame.emitted) add(1); // , + frame.emitted = true; + add(jsonStringUtf8Bytes(key), 1); // key and : + stack.push(frame, { kind: "value", value: item }); + scheduled = true; + break; + } + if (!scheduled) { + add(1); // } + ancestors.delete(frame.value); + } + continue; + } + + const item = frame.value; + if (item === null) { + add(4); + } else if (typeof item === "string") { + add(jsonStringUtf8Bytes(item)); + } else if (typeof item === "number") { + add(Number.isFinite(item) ? (Object.is(item, -0) ? "0" : String(item)).length : 4); + } else if (typeof item === "boolean") { + add(item ? 4 : 5); + } else if (typeof item === "bigint") { + throw new TypeError("Do not know how to serialize a BigInt"); + } else if (Array.isArray(item)) { + if (Object.getPrototypeOf(item) !== Array.prototype) { + throw new TypeError("logical JSON graph containers must be plain objects or arrays"); + } + if (ancestors.has(item)) throw new TypeError("Converting circular structure to JSON"); + ancestors.add(item); + add(1); // [ + stack.push({ kind: "array", value: item, index: 0, length: item.length }); + } else if (typeof item === "object") { + const object = item as Record; + if (ancestors.has(object)) throw new TypeError("Converting circular structure to JSON"); + ancestors.add(object); + add(1); // { + stack.push({ kind: "object", value: object, keys: plainJsonObjectKeys(object), index: 0, emitted: false }); + } else { + throw new TypeError("logical JSON graph contains an unsupported value"); + } + } + return { count: 1, logicalJsonUtf8Bytes }; +} + +interface FindingIdentityAccumulator { + count: number; + logicalUtf8Bytes: number; +} + function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; if (value && typeof value === "object") { @@ -37,8 +253,16 @@ function canonical(value: unknown): string { return JSON.stringify(value); } -function stableId(kind: FindingKind, identity: unknown): string { - const hash = createHash("sha256").update(`${kind}\0${canonical(identity)}`).digest("hex").slice(0, 16); +function stableId(kind: FindingKind, identity: unknown, accounting?: FindingIdentityAccumulator): string { + const materializedIdentity = `${kind}\0${canonical(identity)}`; + if (accounting) { + accounting.count = safeByteSum("finding identity string count", [accounting.count, 1]); + accounting.logicalUtf8Bytes = safeByteSum("finding identity string byte count", [ + accounting.logicalUtf8Bytes, + Buffer.byteLength(materializedIdentity, "utf8"), + ]); + } + const hash = createHash("sha256").update(materializedIdentity).digest("hex").slice(0, 16); return `${kind}:${hash}`; } @@ -49,11 +273,15 @@ export function runtimeKind(error: RuntimeErrorReport): FindingKind { return "runtime.error"; } -export function enrichRuntimeError(error: RuntimeErrorReport, storySeed?: number) { +function enrichRuntimeErrorInternal( + error: RuntimeErrorReport, + storySeed?: number, + identityAccounting?: FindingIdentityAccumulator +) { const kind = runtimeKind(error); return { ...error, - id: stableId(kind, runtimeFindingIdentity(error)), + id: stableId(kind, runtimeFindingIdentity(error), identityAccounting), kind, replay: { tool: "playtest_story" as const, choices: [...error.choiceIndices], storySeed }, witness: { @@ -66,11 +294,19 @@ export function enrichRuntimeError(error: RuntimeErrorReport, storySeed?: number }; } -export function enrichEnding(ending: EndingReport, storySeed?: number) { +export function enrichRuntimeError(error: RuntimeErrorReport, storySeed?: number) { + return enrichRuntimeErrorInternal(error, storySeed); +} + +function enrichEndingInternal( + ending: EndingReport, + storySeed?: number, + identityAccounting?: FindingIdentityAccumulator +) { const kind = "ending.reached" as const; return { ...ending, - id: stableId(kind, { finalText: ending.finalText, variables: ending.variables }), + id: stableId(kind, { finalText: ending.finalText, variables: ending.variables }, identityAccounting), kind, replay: { tool: "playtest_story" as const, choices: [...ending.choiceIndices], storySeed }, witness: { @@ -82,11 +318,19 @@ export function enrichEnding(ending: EndingReport, storySeed?: number) { }; } -export function enrichAssertionViolation(violation: AssertionViolation, storySeed?: number) { +export function enrichEnding(ending: EndingReport, storySeed?: number) { + return enrichEndingInternal(ending, storySeed); +} + +function enrichAssertionViolationInternal( + violation: AssertionViolation, + storySeed?: number, + identityAccounting?: FindingIdentityAccumulator +) { const kind = "assertion.violation" as const; return { ...violation, - id: stableId(kind, { ruleId: violation.ruleId }), + id: stableId(kind, { ruleId: violation.ruleId }, identityAccounting), kind, replay: { tool: "playtest_story" as const, choices: [...violation.choiceIndices], storySeed }, witness: { @@ -99,15 +343,27 @@ export function enrichAssertionViolation(violation: AssertionViolation, storySee }; } -function enrichAssertionResult(result: AssertionResult, storySeed?: number) { - return { ...result, violations: result.violations.map((violation) => enrichAssertionViolation(violation, storySeed)) }; +export function enrichAssertionViolation(violation: AssertionViolation, storySeed?: number) { + return enrichAssertionViolationInternal(violation, storySeed); } -function enrichGoalResult(result: GoalResult, storySeed?: number) { +function enrichAssertionResult( + result: AssertionResult, + storySeed?: number, + identityAccounting?: FindingIdentityAccumulator +) { + return { + ...result, + violations: result.violations.map((violation) => + enrichAssertionViolationInternal(violation, storySeed, identityAccounting)), + }; +} + +function enrichGoalResult(result: GoalResult, storySeed?: number, identityAccounting?: FindingIdentityAccumulator) { const kind = "goal.reached" as const; const enrichWitness = (witness: NonNullable, identity: Record) => ({ ...witness, - id: stableId(kind, { ...identity, choiceIndices: witness.choiceIndices }), + id: stableId(kind, { ...identity, choiceIndices: witness.choiceIndices }, identityAccounting), kind, replay: { operation: "playtest_story" as const, choices: witness.choiceIndices, storySeed }, suggestedAction: "inspect_goal_witness" as const, @@ -169,7 +425,10 @@ function compileKind(issue: CompileResult["issues"][number]): FindingKind { return "compile.error"; } -export function enrichCompile(compile: Omit) { +function enrichCompileInternal( + compile: Omit, + identityAccounting?: FindingIdentityAccumulator +) { return { ...compile, issues: compile.issues.map((issue) => { @@ -180,7 +439,7 @@ export function enrichCompile(compile: Omit) { file: issue.file, line: issue.line, message: issue.message, - }), + }, identityAccounting), kind, suggestedAction: "inspect_source" as const, documentation: `inkcheck://findings/${kind}`, @@ -189,6 +448,10 @@ export function enrichCompile(compile: Omit) { }; } +export function enrichCompile(compile: Omit) { + return enrichCompileInternal(compile); +} + export function buildCompileFailureEnvelope( compile: Omit, file: string, @@ -220,14 +483,18 @@ interface ReportInput { configuration: EffectiveReportConfiguration; } -export function buildReportEnvelope(input: ReportInput) { +function buildReportEnvelopeInternal(input: ReportInput, identityAccounting?: FindingIdentityAccumulator) { const storySeed = input.explore.limits.storySeed; const explore = { ...input.explore, - endingsFound: input.explore.endingsFound.map((ending) => enrichEnding(ending, storySeed)), - runtimeErrors: input.explore.runtimeErrors.map((error) => enrichRuntimeError(error, storySeed)), - assertionResults: input.explore.assertionResults.map((result) => enrichAssertionResult(result, storySeed)), - ...(input.explore.goalResults ? { goalResults: input.explore.goalResults.map((result) => enrichGoalResult(result, storySeed)) } : {}), + endingsFound: input.explore.endingsFound.map((ending) => + enrichEndingInternal(ending, storySeed, identityAccounting)), + runtimeErrors: input.explore.runtimeErrors.map((error) => + enrichRuntimeErrorInternal(error, storySeed, identityAccounting)), + assertionResults: input.explore.assertionResults.map((result) => enrichAssertionResult(result, storySeed, identityAccounting)), + ...(input.explore.goalResults + ? { goalResults: input.explore.goalResults.map((result) => enrichGoalResult(result, storySeed, identityAccounting)) } + : {}), }; return { schemaVersion: REPORT_SCHEMA_VERSION, @@ -242,7 +509,7 @@ export function buildReportEnvelope(input: ReportInput) { limits: { ...input.explore.limits }, }, bindingLimit: bindingLimit(input.explore), - compile: enrichCompile(input.compile), + compile: enrichCompileInternal(input.compile, identityAccounting), ...(input.stats ? { stats: input.stats } : {}), ...(input.profile ? { profile: input.profile } : {}), explore, @@ -251,3 +518,33 @@ export function buildReportEnvelope(input: ReportInput) { ...(input.runs ? { runs: input.runs } : {}), }; } + +export function buildReportEnvelope(input: ReportInput) { + return buildReportEnvelopeInternal(input); +} + +export function buildReportEnvelopeAccounted(input: ReportInput): { + report: ReturnType; + accounting: ReportEnrichmentAccountingV1; +} { + const sourceExploreGraph = measureLogicalJsonGraphV1(input.explore); + const findingIdentityStrings: FindingIdentityAccumulator = { count: 0, logicalUtf8Bytes: 0 }; + const report = buildReportEnvelopeInternal(input, findingIdentityStrings); + const returnedReportGraph = measureLogicalJsonGraphV1(report); + return { + report, + accounting: { + schemaVersion: 1, + sourceExploreGraph, + findingIdentityStrings: { ...findingIdentityStrings }, + returnedReportGraph, + graphOwnership: { + conservativePotentialLogicalJsonUtf8Bytes: safeByteSum( + "report enrichment graph byte count", + [sourceExploreGraph.logicalJsonUtf8Bytes, returnedReportGraph.logicalJsonUtf8Bytes] + ), + currentReturnedLogicalJsonUtf8Bytes: returnedReportGraph.logicalJsonUtf8Bytes, + }, + }, + }; +} diff --git a/test/checkpoint-read-accounting.test.js b/test/checkpoint-read-accounting.test.js new file mode 100644 index 0000000..b6b91bd --- /dev/null +++ b/test/checkpoint-read-accounting.test.js @@ -0,0 +1,480 @@ +const { test } = require("node:test"); +const assert = require("node:assert"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { gunzipSync, gzipSync } = require("node:zlib"); + +const { compile, scanKnots } = require("../dist/inklecate"); +const { exploreSharedResumable } = require("../dist/explore"); +const { + CheckpointReadError, + loadCheckpointForResume, + openCheckpointArtifact, + saveCheckpointArtifact, +} = require("../dist/checkpoints"); + +const FIXTURE = path.join(__dirname, "fixtures", "search", "low-dedup-wide.ink"); + +async function savedCheckpoint() { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "inkcheck-checkpoint-read-accounting-")); + const entrypoint = path.join(projectRoot, "story.ink"); + fs.copyFileSync(FIXTURE, entrypoint); + const compiled = await compile(entrypoint); + assert.strictEqual(compiled.success, true); + const checkpoint = exploreSharedResumable(compiled.storyJson, scanKnots(entrypoint), [], { + maxDepth: 150, + maxStates: 10, + seed: 7, + preserveTurnState: false, + preserveRandomState: false, + }).checkpoint; + assert.ok(checkpoint); + const reference = await saveCheckpointArtifact(projectRoot, entrypoint, checkpoint); + const payloadFile = path.join(projectRoot, ...reference.path.split("/")); + const manifestFile = payloadFile.replace(/\.json(?:\.gz)?$/, ".meta.json"); + return { projectRoot, entrypoint, checkpoint, reference, payloadFile, manifestFile }; +} + +function potentialTotal(accounting) { + return accounting.manifest.storedBuffer.bytes + + accounting.manifest.rawString.logicalUtf8Bytes + + accounting.manifest.parsedGraph.sourceLogicalUtf8Bytes + + accounting.manifest.validationString.logicalUtf8Bytes + + accounting.storedPayload.bytes + + accounting.decompressedPayload.bytes + + accounting.rawArtifactString.logicalUtf8Bytes + + accounting.parsedArtifactGraph.sourceLogicalUtf8Bytes + + accounting.validationTraversal.peakSourceChunkUtf8Bytes + + accounting.validationTraversal.configurationStrings.logicalUtf8Bytes; +} + +async function countPayloadOpensAndParses(payloadFile, rawArtifact, operation) { + const originalOpenSync = fs.openSync; + const originalParse = JSON.parse; + let payloadOpens = 0; + let artifactParses = 0; + fs.openSync = (candidate, ...args) => { + if (path.resolve(String(candidate)) === path.resolve(payloadFile)) payloadOpens += 1; + return originalOpenSync(candidate, ...args); + }; + JSON.parse = (raw, ...args) => { + if (raw === rawArtifact) artifactParses += 1; + return originalParse(raw, ...args); + }; + try { + return { result: await operation(), payloadOpens, artifactParses }; + } finally { + fs.openSync = originalOpenSync; + JSON.parse = originalParse; + } +} + +test("gzip checkpoint reopen accounts existing owners without an extra payload read or parse", async () => { + const fixture = await savedCheckpoint(); + try { + const compressed = fs.readFileSync(fixture.payloadFile); + const rawArtifact = gunzipSync(compressed).toString("utf8"); + const manifestBytes = fs.statSync(fixture.manifestFile).size; + const checkpointBytes = Buffer.byteLength(JSON.stringify(fixture.checkpoint), "utf8"); + const expectedId = `checkpoint-${crypto.createHash("sha256") + .update("story.ink").update("\0").update(JSON.stringify(fixture.checkpoint)) + .digest("hex").slice(0, 24)}`; + assert.strictEqual(fixture.reference.id, expectedId); + + const openedProbe = await countPayloadOpensAndParses( + fixture.payloadFile, + rawArtifact, + () => openCheckpointArtifact(fixture.projectRoot, fixture.reference.id) + ); + assert.strictEqual(openedProbe.payloadOpens, 1, "open reads and hashes the payload from one existing buffer"); + assert.strictEqual(openedProbe.artifactParses, 1, "open parses the artifact once"); + const opened = openedProbe.result; + const accounting = opened.accounting; + assert.strictEqual(accounting.schemaVersion, 1); + assert.strictEqual(accounting.status, "completed"); + assert.strictEqual(accounting.storageEncoding, "gzip"); + assert.strictEqual(accounting.configuredLimits.basis, "configured_limits_not_allocated_capacity"); + assert.ok(accounting.configuredLimits.maxManifestBytes >= manifestBytes); + assert.deepStrictEqual(accounting.manifest.storedBuffer, { count: 1, bytes: manifestBytes }); + assert.deepStrictEqual(accounting.manifest.rawString, { + count: 1, + logicalUtf8Bytes: manifestBytes, + }); + assert.deepStrictEqual(accounting.manifest.parsedGraph, { + count: 1, + sourceLogicalUtf8Bytes: manifestBytes, + }); + assert.strictEqual(accounting.manifest.validationString.count, 1); + assert.ok(accounting.manifest.validationString.logicalUtf8Bytes > 0); + assert.deepStrictEqual(accounting.storedPayload, { + status: "applied", + count: 1, + bytes: compressed.length, + }); + assert.deepStrictEqual(accounting.decompressedPayload, { + status: "applied", + count: 1, + bytes: Buffer.byteLength(rawArtifact, "utf8"), + }); + assert.deepStrictEqual(accounting.rawArtifactString, { + status: "applied", + count: 1, + logicalUtf8Bytes: Buffer.byteLength(rawArtifact, "utf8"), + }); + assert.deepStrictEqual(accounting.parsedArtifactGraph, { + status: "applied", + count: 1, + sourceLogicalUtf8Bytes: Buffer.byteLength(rawArtifact, "utf8"), + }); + assert.strictEqual(accounting.validationTraversal.status, "applied"); + assert.strictEqual(accounting.validationTraversal.count, 1); + assert.strictEqual(accounting.validationTraversal.logicalCheckpointUtf8BytesVisited, checkpointBytes); + assert.ok(accounting.validationTraversal.peakSourceChunkUtf8Bytes > 0); + assert.ok(accounting.validationTraversal.peakSourceChunkUtf8Bytes <= checkpointBytes); + assert.strictEqual(accounting.validationTraversal.configurationStrings.count, 2); + assert.ok(accounting.validationTraversal.configurationStrings.logicalUtf8Bytes > 0); + assert.ok( + accounting.validationTraversal.configurationStrings.peakStringUtf8Bytes + <= accounting.validationTraversal.configurationStrings.logicalUtf8Bytes + ); + assert.strictEqual(accounting.conservativePotential.totalBytes, potentialTotal(accounting)); + assert.deepStrictEqual(accounting.currentReturnedGraph, { + status: "not_applied", + count: 0, + logicalUtf8Bytes: 0, + }); + + const resumedProbe = await countPayloadOpensAndParses( + fixture.payloadFile, + rawArtifact, + () => loadCheckpointForResume(fixture.projectRoot, fixture.reference.id) + ); + assert.strictEqual(resumedProbe.payloadOpens, 1); + assert.strictEqual(resumedProbe.artifactParses, 1); + assert.deepStrictEqual(resumedProbe.result.checkpoint, fixture.checkpoint); + assert.deepStrictEqual(resumedProbe.result.accounting.currentReturnedGraph, { + status: "applied", + count: 1, + logicalUtf8Bytes: checkpointBytes, + }); + assert.strictEqual( + resumedProbe.result.accounting.conservativePotential.totalBytes, + accounting.conservativePotential.totalBytes, + "transferring the already-parsed checkpoint graph does not count a second owner" + ); + } finally { + fs.rmSync(fixture.projectRoot, { recursive: true, force: true }); + } +}); + +test("sidecar-free legacy JSON uses the stored buffer directly and preserves stable identity", async () => { + const fixture = await savedCheckpoint(); + try { + const rawArtifact = gunzipSync(fs.readFileSync(fixture.payloadFile)).toString("utf8"); + const legacyFile = fixture.payloadFile.slice(0, -3); + fs.writeFileSync(legacyFile, rawArtifact, { mode: 0o600 }); + fs.rmSync(fixture.payloadFile); + fs.rmSync(fixture.manifestFile); + + const probe = await countPayloadOpensAndParses( + legacyFile, + rawArtifact, + () => openCheckpointArtifact(fixture.projectRoot, fixture.reference.id) + ); + assert.strictEqual(probe.payloadOpens, 1); + assert.strictEqual(probe.artifactParses, 1); + assert.strictEqual(probe.result.artifact.id, fixture.reference.id); + assert.strictEqual(probe.result.artifact.storageEncoding, "json"); + const accounting = probe.result.accounting; + assert.strictEqual(accounting.storageEncoding, "json"); + assert.strictEqual(accounting.manifest.status, "not_present"); + assert.deepStrictEqual(accounting.manifest.storedBuffer, { count: 0, bytes: 0 }); + assert.deepStrictEqual(accounting.manifest.validationString, { count: 0, logicalUtf8Bytes: 0 }); + assert.deepStrictEqual(accounting.storedPayload, { + status: "applied", + count: 1, + bytes: Buffer.byteLength(rawArtifact, "utf8"), + }); + assert.deepStrictEqual(accounting.decompressedPayload, { + status: "not_applied", + count: 0, + bytes: 0, + }); + assert.strictEqual( + accounting.validationTraversal.logicalCheckpointUtf8BytesVisited, + fixture.reference.accounting.checkpointGraph.logicalUtf8Bytes, + "read validation reuses the same canonical checkpoint traversal as the stable write identity" + ); + assert.strictEqual(accounting.conservativePotential.totalBytes, potentialTotal(accounting)); + } finally { + fs.rmSync(fixture.projectRoot, { recursive: true, force: true }); + } +}); + +test("stored and decompressed read limits attach truthful partial receipts", async () => { + const fixture = await savedCheckpoint(); + try { + await assert.rejects( + () => loadCheckpointForResume(fixture.projectRoot, fixture.reference.id, { maxStoredBytes: 1 }), + (error) => { + assert.ok(error instanceof CheckpointReadError); + assert.strictEqual(error.kind, "resource_limit"); + assert.strictEqual(error.stage, "storage"); + assert.strictEqual(error.accounting.status, "failed"); + assert.deepStrictEqual(error.accounting.failure, { kind: "resource_limit", stage: "storage" }); + assert.strictEqual(error.accounting.configuredLimits.maxStoredBytes, 1); + assert.strictEqual(error.accounting.manifest.status, "applied"); + assert.deepStrictEqual(error.accounting.storedPayload, { + status: "not_completed", + count: 0, + bytes: 0, + }); + assert.strictEqual(error.accounting.conservativePotential.totalBytes, potentialTotal(error.accounting)); + return true; + } + ); + + await assert.rejects( + () => openCheckpointArtifact(fixture.projectRoot, fixture.reference.id, { maxDecompressedBytes: 1 }), + (error) => { + assert.ok(error instanceof CheckpointReadError); + assert.strictEqual(error.kind, "resource_limit"); + assert.strictEqual(error.stage, "decompression"); + assert.strictEqual(error.payloadVerified, true); + assert.strictEqual(error.accounting.configuredLimits.maxDecompressedBytes, 1); + assert.strictEqual(error.accounting.storedPayload.status, "applied"); + assert.deepStrictEqual(error.accounting.decompressedPayload, { + status: "not_completed", + count: 0, + bytes: 0, + }); + assert.strictEqual(error.accounting.rawArtifactString.status, "not_completed"); + assert.strictEqual(error.accounting.conservativePotential.totalBytes, potentialTotal(error.accounting)); + return true; + } + ); + } finally { + fs.rmSync(fixture.projectRoot, { recursive: true, force: true }); + } +}); + +test("corrupt and unsupported artifacts retain the owners reached before failure", async () => { + const fixture = await savedCheckpoint(); + try { + const original = gunzipSync(fs.readFileSync(fixture.payloadFile)).toString("utf8"); + fs.rmSync(fixture.manifestFile); + + fs.writeFileSync(fixture.payloadFile, gzipSync("{")); + await assert.rejects( + () => openCheckpointArtifact(fixture.projectRoot, fixture.reference.id), + (error) => { + assert.ok(error instanceof CheckpointReadError); + assert.deepStrictEqual(error.accounting.failure, { kind: "corrupt", stage: "json" }); + assert.strictEqual(error.accounting.storedPayload.status, "applied"); + assert.strictEqual(error.accounting.decompressedPayload.status, "applied"); + assert.strictEqual(error.accounting.rawArtifactString.status, "applied"); + assert.strictEqual(error.accounting.parsedArtifactGraph.status, "not_completed"); + assert.strictEqual(error.accounting.validationTraversal.status, "not_completed"); + assert.strictEqual(error.accounting.conservativePotential.totalBytes, potentialTotal(error.accounting)); + return true; + } + ); + + const unsupported = JSON.parse(original); + unsupported.artifactSchemaVersion = 999; + fs.writeFileSync(fixture.payloadFile, gzipSync(JSON.stringify(unsupported))); + await assert.rejects( + () => openCheckpointArtifact(fixture.projectRoot, fixture.reference.id), + (error) => { + assert.ok(error instanceof CheckpointReadError); + assert.deepStrictEqual(error.accounting.failure, { kind: "unsupported", stage: "envelope" }); + assert.strictEqual(error.accounting.parsedArtifactGraph.status, "applied"); + assert.strictEqual(error.accounting.validationTraversal.status, "not_completed"); + assert.strictEqual(error.accounting.currentReturnedGraph.status, "not_applied"); + return true; + } + ); + + const wrongIdentity = JSON.parse(original); + wrongIdentity.checkpoint.state.totalGranted += 1; + fs.writeFileSync(fixture.payloadFile, gzipSync(JSON.stringify(wrongIdentity))); + await assert.rejects( + () => openCheckpointArtifact(fixture.projectRoot, fixture.reference.id), + (error) => { + assert.ok(error instanceof CheckpointReadError); + assert.deepStrictEqual(error.accounting.failure, { kind: "corrupt", stage: "envelope" }); + assert.strictEqual(error.accounting.validationTraversal.status, "applied"); + assert.ok(error.accounting.validationTraversal.logicalCheckpointUtf8BytesVisited > 0); + assert.strictEqual(error.accounting.currentReturnedGraph.count, 0); + return true; + } + ); + } finally { + fs.rmSync(fixture.projectRoot, { recursive: true, force: true }); + } +}); + +test("a manifest appearing after the initial check is included by final validation accounting", async () => { + const fixture = await savedCheckpoint(); + try { + const originalExistsSync = fs.existsSync; + const originalOpenSync = fs.openSync; + let manifestExistenceChecks = 0; + let manifestOpens = 0; + let payloadOpens = 0; + fs.existsSync = (candidate) => { + if (path.resolve(String(candidate)) === path.resolve(fixture.manifestFile)) { + manifestExistenceChecks += 1; + if (manifestExistenceChecks === 1) return false; + } + return originalExistsSync(candidate); + }; + fs.openSync = (candidate, ...args) => { + const resolved = path.resolve(String(candidate)); + if (resolved === path.resolve(fixture.manifestFile)) manifestOpens += 1; + if (resolved === path.resolve(fixture.payloadFile)) payloadOpens += 1; + return originalOpenSync(candidate, ...args); + }; + let opened; + try { + opened = await openCheckpointArtifact(fixture.projectRoot, fixture.reference.id); + } finally { + fs.existsSync = originalExistsSync; + fs.openSync = originalOpenSync; + } + assert.ok(manifestExistenceChecks >= 2); + assert.strictEqual(manifestOpens, 1); + assert.strictEqual(payloadOpens, 1); + assert.strictEqual(opened.accounting.manifest.status, "applied"); + assert.strictEqual(opened.accounting.manifest.storedBuffer.count, 1); + assert.strictEqual(opened.accounting.manifest.rawString.count, 1); + assert.strictEqual(opened.accounting.manifest.parsedGraph.count, 1); + assert.strictEqual(opened.accounting.manifest.validationString.count, 1); + assert.strictEqual(opened.accounting.conservativePotential.totalBytes, potentialTotal(opened.accounting)); + } finally { + fs.rmSync(fixture.projectRoot, { recursive: true, force: true }); + } +}); + +test("manifest failures attach a receipt before the payload is opened", async () => { + const fixture = await savedCheckpoint(); + try { + const originalManifest = JSON.parse(fs.readFileSync(fixture.manifestFile, "utf8")); + const unsupportedManifest = { ...originalManifest, manifestSchemaVersion: 999 }; + fs.writeFileSync(fixture.manifestFile, JSON.stringify(unsupportedManifest)); + const originalOpenSync = fs.openSync; + let payloadOpens = 0; + fs.openSync = (candidate, ...args) => { + if (path.resolve(String(candidate)) === path.resolve(fixture.payloadFile)) payloadOpens += 1; + return originalOpenSync(candidate, ...args); + }; + try { + await assert.rejects( + () => openCheckpointArtifact(fixture.projectRoot, fixture.reference.id), + (error) => { + assert.ok(error instanceof CheckpointReadError); + assert.deepStrictEqual(error.accounting.failure, { kind: "unsupported", stage: "manifest" }); + assert.strictEqual(error.accounting.manifest.status, "not_completed"); + assert.strictEqual(error.accounting.manifest.storedBuffer.count, 1); + assert.strictEqual(error.accounting.manifest.rawString.count, 1); + assert.strictEqual(error.accounting.manifest.parsedGraph.count, 1); + assert.strictEqual(error.accounting.manifest.validationString.count, 0); + assert.deepStrictEqual(error.accounting.storedPayload, { + status: "not_completed", + count: 0, + bytes: 0, + }); + return true; + } + ); + + const missingFieldManifest = { ...originalManifest }; + delete missingFieldManifest.entrypoint; + fs.writeFileSync(fixture.manifestFile, JSON.stringify(missingFieldManifest)); + await assert.rejects( + () => openCheckpointArtifact(fixture.projectRoot, fixture.reference.id), + (error) => { + assert.ok(error instanceof CheckpointReadError); + assert.deepStrictEqual(error.accounting.failure, { kind: "corrupt", stage: "manifest" }); + assert.strictEqual(error.accounting.manifest.status, "not_completed"); + assert.strictEqual(error.accounting.manifest.parsedGraph.count, 1); + assert.strictEqual(error.accounting.manifest.validationString.count, 0); + assert.strictEqual(error.accounting.conservativePotential.totalBytes, potentialTotal(error.accounting)); + return true; + } + ); + + const badChecksumManifest = { ...originalManifest, manifestSha256: "0".repeat(64) }; + fs.writeFileSync(fixture.manifestFile, JSON.stringify(badChecksumManifest)); + await assert.rejects( + () => openCheckpointArtifact(fixture.projectRoot, fixture.reference.id), + (error) => { + assert.ok(error instanceof CheckpointReadError); + assert.deepStrictEqual(error.accounting.failure, { kind: "corrupt", stage: "manifest" }); + assert.strictEqual(error.accounting.manifest.status, "not_completed"); + assert.strictEqual(error.accounting.manifest.parsedGraph.count, 1); + assert.strictEqual(error.accounting.manifest.validationString.count, 1); + assert.strictEqual(error.accounting.conservativePotential.totalBytes, potentialTotal(error.accounting)); + return true; + } + ); + } finally { + fs.openSync = originalOpenSync; + } + assert.strictEqual(payloadOpens, 0); + } finally { + fs.rmSync(fixture.projectRoot, { recursive: true, force: true }); + } +}); + +test("post-allocation storage races retain allocated payload capacity in the partial receipt", async () => { + const fixture = await savedCheckpoint(); + try { + const payloadBytes = fs.statSync(fixture.payloadFile).size; + const originalOpenSync = fs.openSync; + const originalCloseSync = fs.closeSync; + const originalReadSync = fs.readSync; + const payloadFds = new Set(); + fs.openSync = (candidate, ...args) => { + const fd = originalOpenSync(candidate, ...args); + if (path.resolve(String(candidate)) === path.resolve(fixture.payloadFile)) payloadFds.add(fd); + return fd; + }; + fs.closeSync = (fd) => { + payloadFds.delete(fd); + return originalCloseSync(fd); + }; + fs.readSync = (fd, buffer, offset, length, position) => { + if (payloadFds.has(fd) && position === payloadBytes && length === 1) { + buffer[offset] = 0; + return 1; + } + return originalReadSync(fd, buffer, offset, length, position); + }; + try { + await assert.rejects( + () => openCheckpointArtifact(fixture.projectRoot, fixture.reference.id), + (error) => { + assert.ok(error instanceof CheckpointReadError); + assert.deepStrictEqual(error.accounting.failure, { kind: "corrupt", stage: "storage" }); + assert.deepStrictEqual(error.accounting.storedPayload, { + status: "not_completed", + count: 1, + bytes: payloadBytes, + }); + assert.strictEqual(error.accounting.decompressedPayload.status, "not_completed"); + assert.strictEqual(error.accounting.conservativePotential.totalBytes, potentialTotal(error.accounting)); + return true; + } + ); + } finally { + fs.openSync = originalOpenSync; + fs.closeSync = originalCloseSync; + fs.readSync = originalReadSync; + } + } finally { + fs.rmSync(fixture.projectRoot, { recursive: true, force: true }); + } +}); diff --git a/test/inkcheck.test.js b/test/inkcheck.test.js index bc3fccf..26b8f65 100644 --- a/test/inkcheck.test.js +++ b/test/inkcheck.test.js @@ -1354,6 +1354,12 @@ test("CLI saves, lists, and reopens source-bound report artifacts by stable ID", fs.writeFileSync(story, "Start\n* [One]\n -> END\n* [Two]\n -> END\n"); const ordinary = spawnSync(process.execPath, args, { cwd: tmp, encoding: "utf8" }); assert.strictEqual(ordinary.status, 0, ordinary.stderr); + const ordinaryOutput = JSON.parse(ordinary.stdout); + const ordinaryOwners = ordinaryOutput.resources.ownerAccounting; + assert.strictEqual(ordinaryOwners.schemaVersion, 1); + assert.strictEqual(ordinaryOwners.reportEnrichment.schemaVersion, 1); + assert.strictEqual("checkpointRead" in ordinaryOwners, false); + assert.strictEqual("reportFinalization" in ordinaryOwners, false); const savedRun = spawnSync(process.execPath, [...args, "--save-report"], { cwd: tmp, encoding: "utf8" }); assert.strictEqual(savedRun.status, 0, savedRun.stderr); const saved = JSON.parse(savedRun.stdout); @@ -1361,6 +1367,26 @@ test("CLI saves, lists, and reopens source-bound report artifacts by stable ID", assert.strictEqual(saved.artifact.path, `.inkcheck/reports/${saved.artifact.id}.json`); assert.strictEqual(saved.artifact.accounting.schemaVersion, 1); assert.strictEqual(saved.artifact.accounting.outcome, "created"); + assert.strictEqual(saved.artifact.accounting.reportEnrichment.status, "applied"); + assert.deepStrictEqual( + saved.resources.ownerAccounting.reportEnrichment, + saved.artifact.accounting.reportEnrichment.accounting + ); + assert.deepStrictEqual( + saved.resources.ownerAccounting.reportFinalization, + saved.artifact.accounting + ); + const ownerAccountingJson = JSON.stringify(saved.resources.ownerAccounting); + assert.ok(Buffer.byteLength(ownerAccountingJson, "utf8") < 32 * 1024); + assert.doesNotMatch(ownerAccountingJson, /Start|\bOne\b|\bTwo\b|story\.ink|\.inkcheck/i); + assert.strictEqual( + saved.artifact.accounting.graphMaterialization.createdEnvelope.status, + "applied" + ); + assert.strictEqual( + saved.artifact.accounting.graphMaterialization.reuseParsedArtifact.status, + "not_applied" + ); assert.strictEqual(saved.artifact.accounting.serialization.status, "applied"); assert.match(savedRun.stderr, new RegExp(`saved report ${saved.artifact.id}`)); const artifactFile = path.join(tmp, ...saved.artifact.path.split("/")); @@ -1373,15 +1399,31 @@ test("CLI saves, lists, and reopens source-bound report artifacts by stable ID", assert.strictEqual(fs.statSync(path.dirname(artifactFile)).mode & 0o777, 0o700); assert.strictEqual(fs.statSync(artifactFile).mode & 0o777, 0o600); } - const withoutReference = { ...saved }; + const persistedArtifact = JSON.parse(fs.readFileSync(artifactFile, "utf8")); + assert.strictEqual("resources" in persistedArtifact.report, false, + "runtime owner receipts stay outside the stable saved report payload"); + const withoutReference = structuredClone(saved); delete withoutReference.artifact; - assert.deepStrictEqual(withoutReference, JSON.parse(ordinary.stdout)); + delete withoutReference.resources.ownerAccounting.reportFinalization; + assert.deepStrictEqual(withoutReference, ordinaryOutput); const repeated = spawnSync(process.execPath, [...args, "--save-report"], { cwd: tmp, encoding: "utf8" }); const repeatedOutput = JSON.parse(repeated.stdout); assert.strictEqual(repeatedOutput.artifact.id, saved.artifact.id); assert.strictEqual(repeatedOutput.artifact.accounting.outcome, "reused"); assert.strictEqual(repeatedOutput.artifact.accounting.serialization.status, "not_applied"); + assert.strictEqual( + repeatedOutput.artifact.accounting.graphMaterialization.createdEnvelope.status, + "not_applied" + ); + assert.strictEqual( + repeatedOutput.artifact.accounting.graphMaterialization.reuseParsedArtifact.status, + "applied" + ); + assert.deepStrictEqual( + repeatedOutput.resources.ownerAccounting.reportEnrichment, + ordinaryOwners.reportEnrichment + ); assert.strictEqual(repeatedOutput.artifact.accounting.serialization.logicalArtifactUtf8Bytes, 0); assert.strictEqual( repeatedOutput.artifact.accounting.durable.artifactBytes, @@ -2976,6 +3018,12 @@ test("CLI persists and resumes an exact base-shared trajectory across processes" assert.strictEqual(first.checkpoint.accounting.serialization.status, "applied"); assert.strictEqual(first.checkpoint.accounting.compression.status, "applied"); assert.ok(first.checkpoint.accounting.durable.totalPairBytes > 0); + assert.deepStrictEqual( + first.resources.ownerAccounting.checkpointCommit, + first.checkpoint.accounting + ); + assert.strictEqual(first.resources.ownerAccounting.reportEnrichment.schemaVersion, 1); + assert.strictEqual("checkpointRead" in first.resources.ownerAccounting, false); assert.match(firstRun.stderr, new RegExp(`saved checkpoint ${first.checkpoint.id}`)); const listed = spawnSync(process.execPath, [CLI, "checkpoints", "list", "--json"], { cwd: tmp, encoding: "utf8" }); @@ -2983,7 +3031,15 @@ test("CLI persists and resumes an exact base-shared trajectory across processes" assert.strictEqual(JSON.parse(listed.stdout).checkpoints[0].id, first.checkpoint.id); const shown = spawnSync(process.execPath, [CLI, "checkpoints", "show", first.checkpoint.id, "--json"], { cwd: tmp, encoding: "utf8" }); assert.strictEqual(shown.status, 0, shown.stderr); - assert.strictEqual(JSON.parse(shown.stdout).artifact.freshness, "current"); + const shownOutput = JSON.parse(shown.stdout); + assert.strictEqual(shownOutput.artifact.freshness, "current"); + assert.strictEqual(shownOutput.accounting.schemaVersion, 1); + assert.strictEqual(shownOutput.accounting.status, "completed"); + assert.deepStrictEqual(shownOutput.accounting.currentReturnedGraph, { + status: "not_applied", + count: 0, + logicalUtf8Bytes: 0, + }); assert.strictEqual(shown.stdout.includes("stateJson"), false, "show returns bounded metadata, not the frontier payload"); const resumedRun = spawnSync(process.execPath, [CLI, "resume", first.checkpoint.id, "--max-states", "500", "--progress=off", "--json"], { @@ -2993,6 +3049,41 @@ test("CLI persists and resumes an exact base-shared trajectory across processes" assert.strictEqual(resumedRun.status, 0, resumedRun.stderr); const resumed = JSON.parse(resumedRun.stdout); assert.strictEqual(resumed.checkpoint.resumedFrom, first.checkpoint.id); + assert.strictEqual(resumed.resources.ownerAccounting.checkpointRead.schemaVersion, 1); + assert.strictEqual(resumed.resources.ownerAccounting.checkpointRead.status, "completed"); + assert.strictEqual( + resumed.resources.ownerAccounting.checkpointRead.currentReturnedGraph.count, + 1 + ); + assert.ok( + resumed.resources.ownerAccounting.checkpointRead.currentReturnedGraph.logicalUtf8Bytes > 0 + ); + assert.strictEqual(resumed.resources.ownerAccounting.reportEnrichment.schemaVersion, 1); + assert.deepStrictEqual( + resumed.resources.ownerAccounting.checkpointCommit, + resumed.checkpoint.accounting + ); + + const resumedStream = spawnSync(process.execPath, [ + CLI, "resume", first.checkpoint.id, + "--max-states", "100", "--json-stream", "--concurrency", "1", "--progress=off", + ], { cwd: tmp, encoding: "utf8" }); + assert.strictEqual(resumedStream.status, 0, resumedStream.stderr); + const streamedTerminal = resumedStream.stdout.trim().split("\n") + .map((line) => JSON.parse(line)) + .at(-1); + assert.strictEqual(streamedTerminal.type, "run_end"); + assert.strictEqual(streamedTerminal.resources.ownerAccounting.checkpointRead.schemaVersion, 1); + assert.strictEqual( + streamedTerminal.resources.ownerAccounting.checkpointRead.currentReturnedGraph.count, + 1 + ); + assert.strictEqual( + "reportEnrichment" in streamedTerminal.resources.ownerAccounting, + false, + "bounded JSON streaming does not materialize the monolithic report graph" + ); + assert.strictEqual("reportFinalization" in streamedTerminal.resources.ownerAccounting, false); const fullRun = spawnSync(process.execPath, [CLI, "story.ink", ...common, "--max-states", "500", "--save-checkpoint"], { cwd: tmp, @@ -4866,6 +4957,8 @@ test("--json-stream emits replayable evidence and a bounded terminal summary", ( terminal.resources.memoryCapBytes - terminal.resources.memorySearchLimitBytes ); assert.strictEqual("checkpointCommit" in terminal.resources.ownerAccounting, false); + assert.strictEqual("checkpointRead" in terminal.resources.ownerAccounting, false); + assert.strictEqual("reportEnrichment" in terminal.resources.ownerAccounting, false); assert.strictEqual("reportFinalization" in terminal.resources.ownerAccounting, false); const retainedPasses = terminal.resources.ownerAccounting.sharedRetainedPasses; assert.ok(Array.isArray(retainedPasses)); diff --git a/test/report-enrichment-accounting.test.js b/test/report-enrichment-accounting.test.js new file mode 100644 index 0000000..b113a5a --- /dev/null +++ b/test/report-enrichment-accounting.test.js @@ -0,0 +1,393 @@ +const { test } = require("node:test"); +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const artifacts = require("../dist/artifacts"); +const reportContract = require("../dist/report-contract"); +const { + buildReportEnvelope, + buildReportEnvelopeAccounted, + measureLogicalJsonGraphV1, +} = reportContract; + +function compactUtf8Bytes(value) { + return Buffer.byteLength(JSON.stringify(value), "utf8"); +} + +function reportInput(privateMarker = "PRIVATE-REPORT-MARKER") { + const largeFinding = `${privateMarker}-${"é🧪\"\\\n".repeat(32_768)}`; + const sharedVariables = { score: 7, label: "共享" }; + const explore = { + statesExplored: 3, + endingsFound: [{ + path: ["終わり"], + choiceIndices: [0], + firstDiscoveredAtState: 3, + finalText: largeFinding, + variables: sharedVariables, + }], + runtimeErrors: [{ + message: largeFinding, + path: ["échec"], + choiceIndices: [1], + firstDiscoveredAtState: 2, + sourceLocation: { file: "物語.ink", line: 7, approximate: true }, + }], + assertionResults: [], + runtimeWarnings: [], + unvisitedKnots: [], + visitedKnots: [], + externalFunctionsStubbed: [], + randomnessDetected: false, + truncated: false, + truncatedBy: { + maxDepth: false, + maxStates: false, + beamWidth: false, + frontier: false, + memory: false, + time: false, + }, + exhaustive: true, + limits: { maxDepth: 10, maxStates: 10, seed: 1, storySeed: 2 }, + }; + return { + compile: { + success: true, + issues: [{ + severity: "WARNING", + file: "物語.ink", + line: 1, + message: largeFinding, + raw: largeFinding, + }], + errors: 0, + warnings: 1, + todos: 0, + }, + explore, + nextRun: { + recommendation: "stop", + stop: true, + flags: { maxDepth: 10, maxStates: 10, seed: 1 }, + rationale: "exhaustive fixture", + expectedGain: "none", + }, + storyJson: "{\"inkVersion\":21,\"root\":[\"^こんにちは\",\"end\",null]}", + configuration: { + search: "shared", + minRepro: false, + strict: false, + maxMemoryMb: null, + maxTimeSec: null, + maxFrontierStates: null, + maxFrontierMb: null, + goalMaxStates: 0, + storySeed: 2, + }, + }; +} + +test("accounted report enrichment is deep-equal, nonmutating, UTF-8 exact, and private", () => { + const privateMarker = "PRIVATE-ENRICHMENT-UNICODE-MARKER"; + const input = reportInput(privateMarker); + const before = structuredClone(input); + const ordinary = buildReportEnvelope(input); + const accounted = buildReportEnvelopeAccounted(input); + + assert.deepStrictEqual(accounted.report, ordinary); + assert.deepStrictEqual(input, before, "the opt-in builder does not mutate its report input"); + assert.strictEqual( + accounted.accounting.sourceExploreGraph.logicalJsonUtf8Bytes, + compactUtf8Bytes(input.explore) + ); + assert.strictEqual( + accounted.accounting.returnedReportGraph.logicalJsonUtf8Bytes, + compactUtf8Bytes(accounted.report) + ); + assert.strictEqual(accounted.accounting.findingIdentityStrings.count, 3); + assert.ok(accounted.accounting.findingIdentityStrings.logicalUtf8Bytes > 300_000); + assert.strictEqual( + accounted.accounting.graphOwnership.conservativePotentialLogicalJsonUtf8Bytes, + accounted.accounting.sourceExploreGraph.logicalJsonUtf8Bytes + + accounted.accounting.returnedReportGraph.logicalJsonUtf8Bytes + ); + assert.strictEqual( + accounted.accounting.graphOwnership.currentReturnedLogicalJsonUtf8Bytes, + accounted.accounting.returnedReportGraph.logicalJsonUtf8Bytes + ); + assert.ok( + accounted.accounting.returnedReportGraph.logicalJsonUtf8Bytes > JSON.stringify(accounted.report).length, + "non-ASCII findings distinguish UTF-8 bytes from JavaScript code units" + ); + assert.strictEqual(JSON.stringify(accounted.accounting).includes(privateMarker), false, + "numeric accounting does not copy authored prose or finding content"); +}); + +test("the logical JSON graph counter is streaming over depth and fails closed on active hooks", () => { + const shared = { text: "é🧪", optional: undefined }; + const dag = { left: shared, right: shared, array: [undefined, shared, NaN] }; + assert.strictEqual(measureLogicalJsonGraphV1(dag).logicalJsonUtf8Bytes, compactUtf8Bytes(dag)); + + const escaped = { + [`key-\ud800-\"`]: `\u0000\b\t\n\f\r\u001f\"\\\u2028\u2029\ud800\udc00🧪${"界".repeat(100_000)}`, + numbers: [-0, 0, 1.25, 1e-7, 1e21, Number.MAX_VALUE, NaN, Infinity, -Infinity], + }; + const escapedBytes = compactUtf8Bytes(escaped); + const originalStringify = JSON.stringify; + let graphSerializations = 0; + JSON.stringify = function (value, ...args) { + graphSerializations++; + return originalStringify.call(JSON, value, ...args); + }; + try { + assert.strictEqual(measureLogicalJsonGraphV1(escaped).logicalJsonUtf8Bytes, escapedBytes); + } finally { + JSON.stringify = originalStringify; + } + assert.strictEqual(graphSerializations, 0, + "graph measurement counts strings, keys, and numeric scalars without invoking JSON.stringify"); + + let deep = null; + const depth = 20_000; + for (let index = 0; index < depth; index++) deep = { n: deep }; + assert.strictEqual(measureLogicalJsonGraphV1(deep).logicalJsonUtf8Bytes, depth * 6 + 4, + "the explicit traversal stack does not depend on JSON.stringify's recursion depth"); + + const cyclic = {}; + cyclic.self = cyclic; + assert.throws(() => measureLogicalJsonGraphV1(cyclic), /circular structure/i); + assert.throws(() => measureLogicalJsonGraphV1({ value: 1n }), /BigInt/); + assert.throws(() => measureLogicalJsonGraphV1(new String("boxed")), /plain objects or arrays/); + + let getterCalls = 0; + const accessor = {}; + Object.defineProperty(accessor, "private", { + enumerable: true, + get() { + getterCalls++; + return "should-not-run"; + }, + }); + assert.throws(() => measureLogicalJsonGraphV1(accessor), /accessor properties/); + assert.strictEqual(getterCalls, 0); + + let toJsonCalls = 0; + const activeHook = { + toJSON() { + toJsonCalls++; + return { leaked: true }; + }, + }; + assert.throws(() => measureLogicalJsonGraphV1(activeHook), /toJSON hooks/); + assert.strictEqual(toJsonCalls, 0); + + let callableHookCalls = 0; + function callable() {} + callable.toJSON = () => { + callableHookCalls++; + return { leaked: true }; + }; + assert.throws(() => measureLogicalJsonGraphV1({ callable }), /toJSON hooks/); + assert.strictEqual(callableHookCalls, 0, "callable toJSON hooks are rejected before ordinary function omission"); + + let inheritedHookCalls = 0; + Object.defineProperty(Object.prototype, "toJSON", { + configurable: true, + value() { + inheritedHookCalls++; + return { leaked: true }; + }, + }); + try { + assert.throws(() => measureLogicalJsonGraphV1({ ordinary: true }), /toJSON hooks/); + assert.strictEqual(inheritedHookCalls, 0); + } finally { + delete Object.prototype.toJSON; + } + + let proxyTraps = 0; + const proxy = new Proxy({}, { + getPrototypeOf() { + proxyTraps++; + return Object.prototype; + }, + ownKeys() { + proxyTraps++; + return []; + }, + }); + assert.throws(() => measureLogicalJsonGraphV1(proxy), /Proxy containers/); + assert.strictEqual(proxyTraps, 0); + assert.throws(() => measureLogicalJsonGraphV1(Object.create(proxy)), /Proxy containers/); + assert.strictEqual(proxyTraps, 0, "proxy prototypes are rejected before descriptor reflection"); +}); + +test("report create and reuse account distinct graphs without changing IDs, payloads, or I/O", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "inkcheck-report-enrichment-")); + const file = path.join(root, "物語.ink"); + const privateMarker = "PRIVATE-FINALIZATION-MARKER"; + try { + fs.writeFileSync(file, "こんにちは\n-> END\n"); + const built = buildReportEnvelopeAccounted(reportInput(privateMarker)); + const originalStringify = JSON.stringify; + let prettySerializations = 0; + JSON.stringify = function (value, replacer, space) { + if (space !== undefined) prettySerializations++; + return originalStringify.call(JSON, value, replacer, space); + }; + let created; + try { + created = artifacts.saveReportArtifact(root, file, built.report, {}, built.accounting); + } finally { + JSON.stringify = originalStringify; + } + assert.strictEqual(prettySerializations, 1, "create performs the existing single artifact serialization"); + const destination = path.join(root, ...created.path.split("/")); + const durableBefore = fs.readFileSync(destination); + const savedArtifact = JSON.parse(durableBefore.toString("utf8")); + assert.deepStrictEqual(savedArtifact.report, built.report); + assert.strictEqual(Object.hasOwn(savedArtifact, "accounting"), false); + assert.strictEqual(Object.hasOwn(savedArtifact.report, "accounting"), false); + assert.deepStrictEqual(created.accounting.reportEnrichment, { + status: "applied", + accounting: built.accounting, + }); + assert.strictEqual(created.accounting.graphMaterialization.createdEnvelope.status, "applied"); + assert.strictEqual(created.accounting.graphMaterialization.reuseParsedArtifact.status, "not_applied"); + assert.strictEqual( + created.accounting.graphMaterialization.createdEnvelope.graph.logicalJsonUtf8Bytes, + compactUtf8Bytes(savedArtifact) + ); + assert.deepStrictEqual(created.accounting.graphMaterialization.conservativePotential, { + status: "applied", + logicalJsonUtf8Bytes: created.accounting.graphMaterialization.createdEnvelope.graph.logicalJsonUtf8Bytes, + }); + assert.strictEqual(created.accounting.graphMaterialization.currentLogicalJsonUtf8Bytes, 0); + assert.strictEqual( + created.accounting.serialization.conservativePotentialStringUtf8Bytes, + created.accounting.reportIdentity.logicalUtf8Bytes + + created.accounting.serialization.logicalArtifactUtf8Bytes, + "graph proxies are not folded into the separate string total" + ); + assert.strictEqual(JSON.stringify(created.accounting).includes(privateMarker), false); + + const originalReadFileSync = fs.readFileSync; + const originalParse = JSON.parse; + let artifactReads = 0; + let parses = 0; + prettySerializations = 0; + fs.readFileSync = (candidate, ...args) => { + if (path.resolve(String(candidate)) === path.resolve(destination)) artifactReads++; + return originalReadFileSync(candidate, ...args); + }; + JSON.parse = function (...args) { + parses++; + return originalParse.apply(JSON, args); + }; + JSON.stringify = function (value, replacer, space) { + if (space !== undefined) prettySerializations++; + return originalStringify.call(JSON, value, replacer, space); + }; + let reused; + try { + reused = artifacts.saveReportArtifact(root, file, built.report, {}, built.accounting); + } finally { + fs.readFileSync = originalReadFileSync; + JSON.parse = originalParse; + JSON.stringify = originalStringify; + } + assert.strictEqual(artifactReads, 1, "reuse derives all accounting from the existing validation read"); + assert.strictEqual(parses, 1, "reuse counts the graph returned by the existing validation parse"); + assert.strictEqual(prettySerializations, 0, "reuse does not serialize a replacement artifact"); + assert.deepStrictEqual({ id: reused.id, path: reused.path }, { id: created.id, path: created.path }); + assert.strictEqual(reused.accounting.graphMaterialization.createdEnvelope.status, "not_applied"); + assert.strictEqual(reused.accounting.graphMaterialization.reuseParsedArtifact.status, "applied"); + assert.strictEqual( + reused.accounting.graphMaterialization.reuseParsedArtifact.graph.logicalJsonUtf8Bytes, + compactUtf8Bytes(savedArtifact) + ); + assert.strictEqual( + reused.accounting.serialization.conservativePotentialStringUtf8Bytes, + reused.accounting.reportIdentity.logicalUtf8Bytes + + reused.accounting.readback.rawArtifactString.logicalUtf8Bytes + + reused.accounting.readback.validationIdentity.logicalUtf8Bytes, + "reuse graph proxies are not folded into string materialization totals" + ); + assert.deepStrictEqual(fs.readFileSync(destination), durableBefore); + + const originalMeasure = reportContract.measureLogicalJsonGraphV1; + let legacyGraphWalks = 0; + artifactReads = 0; + parses = 0; + prettySerializations = 0; + fs.readFileSync = (candidate, ...args) => { + if (path.resolve(String(candidate)) === path.resolve(destination)) artifactReads++; + return originalReadFileSync(candidate, ...args); + }; + JSON.parse = function (...args) { + parses++; + return originalParse.apply(JSON, args); + }; + JSON.stringify = function (value, replacer, space) { + if (space !== undefined) prettySerializations++; + return originalStringify.call(JSON, value, replacer, space); + }; + reportContract.measureLogicalJsonGraphV1 = (...args) => { + legacyGraphWalks++; + return originalMeasure(...args); + }; + let legacy; + try { + legacy = artifacts.saveReportArtifact(root, file, built.report); + } finally { + fs.readFileSync = originalReadFileSync; + JSON.parse = originalParse; + JSON.stringify = originalStringify; + reportContract.measureLogicalJsonGraphV1 = originalMeasure; + } + assert.strictEqual(artifactReads, 1); + assert.strictEqual(parses, 1); + assert.strictEqual(prettySerializations, 0); + assert.strictEqual(legacyGraphWalks, 0, "legacy reuse adds no report or artifact graph traversal"); + assert.deepStrictEqual(legacy.accounting.reportEnrichment, { + status: "not_applied", + reason: "unavailable", + }); + assert.strictEqual(Object.hasOwn(legacy.accounting.reportEnrichment, "accounting"), false); + assert.strictEqual(legacy.id, created.id); + assert.deepStrictEqual(legacy.accounting.graphMaterialization.reuseParsedArtifact, { + status: "not_applied", + reason: "unavailable", + }); + assert.deepStrictEqual(legacy.accounting.graphMaterialization.conservativePotential, { + status: "not_applied", + reason: "unavailable", + }); + + const legacyFile = path.join(root, "legacy.ink"); + fs.writeFileSync(legacyFile, "Legacy\n-> END\n"); + const legacyReport = { ...built.report, runs: [new Date(0)] }; + const legacyCreated = artifacts.saveReportArtifact(root, legacyFile, legacyReport); + assert.deepStrictEqual(legacyCreated.accounting.graphMaterialization.createdEnvelope, { + status: "not_applied", + reason: "unavailable", + }); + assert.deepStrictEqual(legacyCreated.accounting.graphMaterialization.conservativePotential, { + status: "not_applied", + reason: "unavailable", + }); + const legacySaved = JSON.parse(fs.readFileSync(path.join(root, ...legacyCreated.path.split("/")), "utf8")); + assert.deepStrictEqual(legacySaved.report.runs, ["1970-01-01T00:00:00.000Z"], + "unaccounted legacy creates preserve the existing serializer's behavior"); + + const tainted = { ...built.accounting, privateMarker }; + const projected = artifacts.saveReportArtifact(root, file, built.report, {}, tainted); + assert.strictEqual(JSON.stringify(projected.accounting).includes(privateMarker), false, + "the finalization receipt projects the trusted numeric schema instead of copying arbitrary keys"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +});