@inflexa-ai/harness 0.19.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/agents/report-session-agent.d.ts +25 -9
  2. package/dist/agents/report-session-agent.js +21 -6
  3. package/dist/app/report-session-runtime.js +26 -0
  4. package/dist/app/spawn-report-session.d.ts +32 -5
  5. package/dist/app/spawn-report-session.js +19 -0
  6. package/dist/config/environment-stores.d.ts +12 -7
  7. package/dist/config/resource-limits.d.ts +6 -10
  8. package/dist/config/resource-limits.js +6 -44
  9. package/dist/contracts/report-reference.d.ts +4 -0
  10. package/dist/contracts/report-reference.js +3 -0
  11. package/dist/index.d.ts +2 -1
  12. package/dist/lib/chrome.d.ts +14 -0
  13. package/dist/lib/chrome.js +7 -0
  14. package/dist/lib/page-capture.d.ts +47 -0
  15. package/dist/lib/page-capture.js +83 -0
  16. package/dist/loop/run-agent.js +104 -15
  17. package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
  18. package/dist/memory/ai-sdk-message-storage.js +9 -4
  19. package/dist/memory/count-tokens.js +34 -5
  20. package/dist/prompts/report-session.d.ts +1 -1
  21. package/dist/prompts/report-session.js +21 -0
  22. package/dist/providers/ai-sdk.d.ts +81 -3
  23. package/dist/providers/ai-sdk.js +91 -10
  24. package/dist/providers/types.d.ts +17 -0
  25. package/dist/report-model/assert-rules.d.ts +44 -0
  26. package/dist/report-model/assert-rules.js +97 -0
  27. package/dist/report-model/draft-hash.d.ts +14 -0
  28. package/dist/report-model/draft-hash.js +36 -0
  29. package/dist/report-model/fixture-resolver.js +2 -71
  30. package/dist/report-model/production-resolver.d.ts +124 -0
  31. package/dist/report-model/production-resolver.js +801 -0
  32. package/dist/report-model/reference-resolver.d.ts +6 -0
  33. package/dist/report-model/validate.js +5 -0
  34. package/dist/report-render/page.d.ts +23 -0
  35. package/dist/report-render/page.js +36 -3
  36. package/dist/runtime/assemble.d.ts +28 -4
  37. package/dist/runtime/assemble.js +42 -3
  38. package/dist/sandbox/mount-plan.d.ts +7 -0
  39. package/dist/sandbox/mount-plan.js +7 -2
  40. package/dist/state/init.js +20 -7
  41. package/dist/state/report-session-state.d.ts +27 -0
  42. package/dist/state/report-session-state.js +29 -2
  43. package/dist/tasks/extract-values-script.d.ts +64 -0
  44. package/dist/tasks/extract-values-script.js +180 -0
  45. package/dist/tasks/extract-values.d.ts +124 -0
  46. package/dist/tasks/extract-values.js +193 -0
  47. package/dist/tools/define-tool.d.ts +29 -0
  48. package/dist/tools/define-tool.js +26 -0
  49. package/dist/tools/report/preview-snapshot.d.ts +1 -1
  50. package/dist/tools/report/preview-snapshot.js +11 -50
  51. package/dist/tools/report-authoring/authoring-tools.d.ts +54 -4
  52. package/dist/tools/report-authoring/authoring-tools.js +1 -1
  53. package/dist/tools/report-session/examine-page.d.ts +90 -0
  54. package/dist/tools/report-session/examine-page.js +136 -0
  55. package/dist/tools/report-session/index.d.ts +2 -0
  56. package/dist/tools/report-session/index.js +2 -0
  57. package/dist/tools/report-session/preview-report.d.ts +20 -5
  58. package/dist/tools/report-session/preview-report.js +28 -4
  59. package/dist/tools/report-session/record-version.d.ts +102 -0
  60. package/dist/tools/report-session/record-version.js +139 -0
  61. package/dist/tools/sandbox/list-available-packages.js +2 -1
  62. package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
  63. package/dist/tools/sandbox/list-available-refs.js +13 -6
  64. package/package.json +10 -8
@@ -1,5 +1,6 @@
1
1
  import { generateText, streamText, wrapLanguageModel, } from "ai";
2
2
  import { createAnthropic } from "@ai-sdk/anthropic";
3
+ import { createOpenAI } from "@ai-sdk/openai";
3
4
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
4
5
  import { APICallError } from "@ai-sdk/provider";
5
6
  import { retryWithExponentialBackoff } from "@ai-sdk/provider-utils";
@@ -28,6 +29,11 @@ export const RETRY_MAX_DELAY_MS = 30_000;
28
29
  *
29
30
  * Lower it per-config for servers that validate `prompt + max_tokens <= context`
30
31
  * (vLLM and similar) against a small-context model.
32
+ *
33
+ * The openai arm takes no value from this default. `@ai-sdk/openai` clamps
34
+ * nothing per model, and it puts the number on the wire as it is. Thus a model
35
+ * whose own cap is smaller answers 400 on each call. That arm sends no ceiling,
36
+ * and the server then enforces the true cap of the model.
31
37
  */
32
38
  export const DEFAULT_MAX_OUTPUT_TOKENS = 64_000;
33
39
  /**
@@ -370,9 +376,20 @@ function abortedStreamFailure(signal, reason, requestTimeoutMs) {
370
376
  return new Error(reason ?? "The provider aborted the stream.");
371
377
  }
372
378
  export function createAiSdkProvider(deps) {
373
- const capabilities = { toolCalling: deps.capabilities?.toolCalling ?? true };
379
+ const capabilities = {
380
+ toolCalling: deps.capabilities?.toolCalling ?? true,
381
+ // Propagate a picture flag only when the caller states it. An absent
382
+ // flag stays absent, thus the loop degrades to the next placement for a
383
+ // wire that carries no picture there.
384
+ ...(deps.capabilities?.imageToolResults !== undefined ? { imageToolResults: deps.capabilities.imageToolResults } : {}),
385
+ ...(deps.capabilities?.imageUserMessages !== undefined ? { imageUserMessages: deps.capabilities.imageUserMessages } : {}),
386
+ };
374
387
  const logger = (deps.logger ?? createNoopLogger()).named("providers.ai-sdk");
375
388
  const maxOutputTokens = deps.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
389
+ // The sentinel hands the SDK no ceiling, thus the request carries none and the
390
+ // server enforces the cap of the model. One binding feeds the call and the
391
+ // stream, thus the two cannot disagree about the ceiling of a request.
392
+ const outputCeiling = maxOutputTokens === "provider-maximum" ? {} : { maxOutputTokens };
376
393
  const maxRetries = deps.maxRetries ?? RETRY_MAX_RETRIES;
377
394
  const requestTimeoutMs = deps.requestTimeoutMs;
378
395
  const requestedModelId = requestedModelIdOf(deps.model);
@@ -392,7 +409,7 @@ export function createAiSdkProvider(deps) {
392
409
  messages: sanitizeMessages(req.messages),
393
410
  tools: req.tools,
394
411
  toolChoice: req.toolChoice ?? "auto",
395
- maxOutputTokens,
412
+ ...outputCeiling,
396
413
  stopWhen: [],
397
414
  // The harness envelope owns retries; leaving the SDK default in
398
415
  // place would multiply attempts (10 outer × 3 inner).
@@ -436,17 +453,19 @@ export function createAiSdkProvider(deps) {
436
453
  messages: sanitizeMessages(req.messages),
437
454
  tools: req.tools,
438
455
  toolChoice: req.toolChoice ?? "auto",
439
- maxOutputTokens,
456
+ ...outputCeiling,
440
457
  stopWhen: [],
441
458
  maxRetries: 0,
442
459
  headers,
443
460
  abortSignal: signal,
444
- // The SDK bounds each gap between two content chunks of the body.
445
- // Its timer arms at the first part of the provider stream, thus the
446
- // wait until the response starts stays with the fetch guard. A
447
- // chunk that carries no content, for example a keep-alive comment,
448
- // produces no part, thus it does not reset this bound.
449
- ...(requestTimeoutMs !== undefined ? { timeout: { chunkMs: requestTimeoutMs } } : {}),
461
+ // The fetch guard bounds the wait until the headers. Past the
462
+ // headers the SDK owns two bounds: `firstChunkMs` covers the wait
463
+ // for the first content chunk, and `chunkMs` covers each later
464
+ // gap. The two take the same value, thus each silent interval of
465
+ // the stream has the one configured bound. A chunk that carries
466
+ // no content, for example a keep-alive comment, produces no part,
467
+ // thus it feeds neither bound.
468
+ ...(requestTimeoutMs !== undefined ? { timeout: { firstChunkMs: requestTimeoutMs, chunkMs: requestTimeoutMs } } : {}),
450
469
  providerOptions: req.providerOptions,
451
470
  });
452
471
  const iterator = result.fullStream[Symbol.asyncIterator]();
@@ -581,6 +600,28 @@ export function wrapFetchWithRequestTimeout(fetchImpl, requestTimeoutMs) {
581
600
  }
582
601
  };
583
602
  }
603
+ /**
604
+ * Bind one explicit `store` directive onto each call of a model.
605
+ *
606
+ * The middleware merges the value into the `openai` namespace of
607
+ * `providerOptions`. It keeps each other namespace, and it keeps each other key
608
+ * of the `openai` namespace, thus a cache directive of a different vendor rides
609
+ * through untouched. The arm value wins over a request-level `store`, because
610
+ * the retention mode belongs to the connection and not to one turn. A thread
611
+ * that mixes the two modes replays a reference onto an item that the server
612
+ * never stored.
613
+ */
614
+ function withStoreDirective(model, store) {
615
+ return wrapLanguageModel({
616
+ model,
617
+ middleware: {
618
+ transformParams: async ({ params }) => ({
619
+ ...params,
620
+ providerOptions: { ...params.providerOptions, openai: { ...params.providerOptions?.openai, store } },
621
+ }),
622
+ },
623
+ });
624
+ }
584
625
  /**
585
626
  * Realize an `AiSdkProviderConfig` into a `ChatProvider` bound to that config's
586
627
  * connection and model. The model is closed into the returned provider here (it
@@ -604,16 +645,56 @@ export function createConfiguredAiSdkProvider(deps) {
604
645
  apiKey: config.apiKey,
605
646
  fetch: effectiveFetch,
606
647
  });
648
+ // The Anthropic Messages wire renders an image block inside a
649
+ // tool_result, thus the loop can send a picture. A custom baseURL can
650
+ // front a different backend that stringifies the block. Thus the
651
+ // harness asserts the capability only for the default endpoint. An
652
+ // absent flag means "cannot carry", thus a config with a custom baseURL
653
+ // must declare the capability. A config value overrides this default in
654
+ // both directions.
655
+ const pictureDefault = config.baseURL === undefined ? { imageToolResults: true } : {};
607
656
  return createAiSdkProvider({
608
657
  model: provider.chat(config.model),
609
658
  resolveBilling: deps.resolveBilling,
610
- capabilities: config.capabilities,
659
+ capabilities: { ...pictureDefault, ...config.capabilities },
611
660
  logger: deps.logger,
612
661
  ...(config.maxOutputTokens !== undefined ? { maxOutputTokens: config.maxOutputTokens } : {}),
613
662
  ...(config.maxRetries !== undefined ? { maxRetries: config.maxRetries } : {}),
614
663
  ...(requestTimeoutMs !== undefined ? { requestTimeoutMs } : {}),
615
664
  });
616
665
  }
666
+ if (config.kind === "openai") {
667
+ const provider = createOpenAI({
668
+ baseURL: config.baseURL,
669
+ apiKey: config.apiKey,
670
+ fetch: effectiveFetch,
671
+ });
672
+ // The Responses wire renders an image block inside a function-call
673
+ // output, thus the loop can send a picture. A custom baseURL can front
674
+ // a different backend that stringifies the block. Thus the harness
675
+ // asserts the capability only for the default endpoint. An absent flag
676
+ // means "cannot carry", thus a config with a custom baseURL must
677
+ // declare the capability. A config value overrides this default in both
678
+ // directions.
679
+ const pictureDefault = config.baseURL === undefined ? { imageToolResults: true } : {};
680
+ return createAiSdkProvider({
681
+ // The explicit Responses binding pins the wire here. The bare
682
+ // factory callable of the package resolves to the same path today,
683
+ // but nothing in the package holds it there.
684
+ model: withStoreDirective(provider.responses(config.model), config.store ?? false),
685
+ resolveBilling: deps.resolveBilling,
686
+ capabilities: { ...pictureDefault, ...config.capabilities },
687
+ logger: deps.logger,
688
+ // The package puts this number on the wire as it is, and it clamps
689
+ // nothing per model. Thus the shared default of 64000 breaks each call
690
+ // to a model with a smaller cap, with a 400 that no retry can pass. An
691
+ // unset config sends no ceiling, and the server holds the reply at the
692
+ // cap of the model.
693
+ maxOutputTokens: config.maxOutputTokens ?? "provider-maximum",
694
+ ...(config.maxRetries !== undefined ? { maxRetries: config.maxRetries } : {}),
695
+ ...(requestTimeoutMs !== undefined ? { requestTimeoutMs } : {}),
696
+ });
697
+ }
617
698
  const provider = createOpenAICompatible({
618
699
  name: config.name,
619
700
  baseURL: config.baseURL,
@@ -13,6 +13,23 @@ import type { ProviderError } from "./errors.js";
13
13
  export type { FinishReason, LanguageModel, ModelMessage, ProviderOptions, ToolSet };
14
14
  export interface ProviderCapabilities {
15
15
  readonly toolCalling: boolean;
16
+ /**
17
+ * Whether the wire can carry a picture inside a tool result. The loop moves a
18
+ * tool's picture into an image content block on the tool-result message. A
19
+ * wire that renders that block sets this flag. A wire that stringifies the
20
+ * block into text leaves it absent, and the loop then drops the picture and
21
+ * keeps the text, because base64 text floods the context and the model cannot
22
+ * see it. Absent means "cannot carry", never "unknown".
23
+ */
24
+ readonly imageToolResults?: boolean;
25
+ /**
26
+ * Whether the wire can carry a picture inside a user message. The loop uses
27
+ * this placement only when the tool result cannot carry the picture. The tool
28
+ * result then keeps its JSON text, and one user message after the tool message
29
+ * of the round carries the bytes. Absent means "cannot carry", never
30
+ * "unknown".
31
+ */
32
+ readonly imageUserMessages?: boolean;
16
33
  }
17
34
  export interface ChatRequest {
18
35
  readonly system: string;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The assert rules of the value tier. A resolver reads a fresh value, and these functions match it
3
+ * against the authored belief.
4
+ *
5
+ * Each realization of the resolver calls these functions. Thus one semantics exists, and the fixture
6
+ * stays the executable specification of the value tier.
7
+ */
8
+ import type { Reference, UnresolvedReference } from "../contracts/report-reference.js";
9
+ /**
10
+ * Read an authored value or a resolved cell as a finite number.
11
+ *
12
+ * A text-backed artifact such as a CSV holds every cell as a string, thus a numeric column arrives as
13
+ * `"0.01"` and not as `0.01`. A comparison that respects the JavaScript type would fail an author who
14
+ * states the number, and the arithmetic of a derivation could never run over a real CSV.
15
+ *
16
+ * The parse reads the whole string, thus `"12 genes"` stays text and never reads as 12. `Number` accepts
17
+ * the exponent form that a p-value uses, for example `"1.2e-45"`. A blank string is not a number, because
18
+ * `Number("")` gives 0 and an empty cell must never read as zero.
19
+ */
20
+ export declare function asFiniteNumber(value: string | number): number | undefined;
21
+ /**
22
+ * Match a row-filter value against a resolved cell.
23
+ *
24
+ * A text-backed artifact such as a CSV holds every cell as a string, thus a filter value of `3` must
25
+ * select the cell `"3"`. The compare reads both sides as a finite number, and it selects the row where the
26
+ * two numbers are equal. A side that is not a finite number compares by exact value, thus a text key
27
+ * selects by its exact text. The match is exact and carries no tolerance, because a filter names a
28
+ * discrete key and not a computed value with float noise.
29
+ */
30
+ export declare function cellMatchesFilterValue(cell: string | number | null | undefined, filterValue: string | number): boolean;
31
+ /**
32
+ * Match a resolved value against an authored one.
33
+ *
34
+ * The comparison is numeric whenever both sides read as a finite number, thus the CSV cell `"0.01"`
35
+ * matches the number 0.01 that an author states. An authored tolerance is an absolute difference, and it
36
+ * is the author's own statement of how close is close enough. With no tolerance the comparison is
37
+ * relative, because an exact match would fail on the float noise of a computed value. Any other pair
38
+ * matches on exact equality.
39
+ */
40
+ export declare function valuesMatch(expected: string | number, actual: string | number, tolerance: number | undefined): boolean;
41
+ /** Match a resolved scalar against the authored value, under the authored tolerance. */
42
+ export declare function checkValueAssertion(reference: Reference, expected: string | number | undefined, tolerance: number | undefined, resolved: string | number): UnresolvedReference | undefined;
43
+ /** Match the resolved citation key against the authored value. The key carries its `idKind:` prefix. */
44
+ export declare function checkCitationAssertion(reference: Reference, expected: string | undefined, resolved: string): UnresolvedReference | undefined;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The assert rules of the value tier. A resolver reads a fresh value, and these functions match it
3
+ * against the authored belief.
4
+ *
5
+ * Each realization of the resolver calls these functions. Thus one semantics exists, and the fixture
6
+ * stays the executable specification of the value tier.
7
+ */
8
+ /**
9
+ * The relative epsilon of a value match with no authored tolerance.
10
+ *
11
+ * Float arithmetic shifts a computed value in its last bits. For example, `0.3 - 0.1` gives
12
+ * 0.19999999999999998, and exact equality would report that noise as a fabrication.
13
+ *
14
+ * The epsilon absorbs the noise and nothing else. It is far below the difference that a rounded figure
15
+ * makes, thus an author who writes 0.05 against a true 0.0499 still fails. An author who wants a rounded
16
+ * figure to pass states a `tolerance`, which is the one place where the intent belongs.
17
+ */
18
+ const RELATIVE_EPSILON = 1e-9;
19
+ /**
20
+ * Read an authored value or a resolved cell as a finite number.
21
+ *
22
+ * A text-backed artifact such as a CSV holds every cell as a string, thus a numeric column arrives as
23
+ * `"0.01"` and not as `0.01`. A comparison that respects the JavaScript type would fail an author who
24
+ * states the number, and the arithmetic of a derivation could never run over a real CSV.
25
+ *
26
+ * The parse reads the whole string, thus `"12 genes"` stays text and never reads as 12. `Number` accepts
27
+ * the exponent form that a p-value uses, for example `"1.2e-45"`. A blank string is not a number, because
28
+ * `Number("")` gives 0 and an empty cell must never read as zero.
29
+ */
30
+ export function asFiniteNumber(value) {
31
+ if (typeof value === "number") {
32
+ return Number.isFinite(value) ? value : undefined;
33
+ }
34
+ if (value.trim() === "") {
35
+ return undefined;
36
+ }
37
+ const parsed = Number(value);
38
+ return Number.isFinite(parsed) ? parsed : undefined;
39
+ }
40
+ /**
41
+ * Match a row-filter value against a resolved cell.
42
+ *
43
+ * A text-backed artifact such as a CSV holds every cell as a string, thus a filter value of `3` must
44
+ * select the cell `"3"`. The compare reads both sides as a finite number, and it selects the row where the
45
+ * two numbers are equal. A side that is not a finite number compares by exact value, thus a text key
46
+ * selects by its exact text. The match is exact and carries no tolerance, because a filter names a
47
+ * discrete key and not a computed value with float noise.
48
+ */
49
+ export function cellMatchesFilterValue(cell, filterValue) {
50
+ if (cell === null || cell === undefined) {
51
+ return false;
52
+ }
53
+ const cellNumber = asFiniteNumber(cell);
54
+ const filterNumber = asFiniteNumber(filterValue);
55
+ if (cellNumber !== undefined && filterNumber !== undefined) {
56
+ return cellNumber === filterNumber;
57
+ }
58
+ return cell === filterValue;
59
+ }
60
+ /** Name a value with its type, so that a failed match never reads as two identical values. */
61
+ function describeValue(value) {
62
+ return typeof value === "string" ? `the string ${JSON.stringify(value)}` : `the number ${value}`;
63
+ }
64
+ /**
65
+ * Match a resolved value against an authored one.
66
+ *
67
+ * The comparison is numeric whenever both sides read as a finite number, thus the CSV cell `"0.01"`
68
+ * matches the number 0.01 that an author states. An authored tolerance is an absolute difference, and it
69
+ * is the author's own statement of how close is close enough. With no tolerance the comparison is
70
+ * relative, because an exact match would fail on the float noise of a computed value. Any other pair
71
+ * matches on exact equality.
72
+ */
73
+ export function valuesMatch(expected, actual, tolerance) {
74
+ const expectedNumber = asFiniteNumber(expected);
75
+ const actualNumber = asFiniteNumber(actual);
76
+ if (expectedNumber !== undefined && actualNumber !== undefined) {
77
+ if (tolerance !== undefined) {
78
+ return Math.abs(expectedNumber - actualNumber) <= tolerance;
79
+ }
80
+ return Math.abs(expectedNumber - actualNumber) <= RELATIVE_EPSILON * Math.max(1, Math.abs(expectedNumber), Math.abs(actualNumber));
81
+ }
82
+ return expected === actual;
83
+ }
84
+ /** Match a resolved scalar against the authored value, under the authored tolerance. */
85
+ export function checkValueAssertion(reference, expected, tolerance, resolved) {
86
+ if (expected === undefined || valuesMatch(expected, resolved, tolerance)) {
87
+ return undefined;
88
+ }
89
+ return { reference, reason: "assertion-failed", detail: `expected ${describeValue(expected)} but resolved ${describeValue(resolved)}` };
90
+ }
91
+ /** Match the resolved citation key against the authored value. The key carries its `idKind:` prefix. */
92
+ export function checkCitationAssertion(reference, expected, resolved) {
93
+ if (expected === undefined || expected === resolved) {
94
+ return undefined;
95
+ }
96
+ return { reference, reason: "assertion-failed", detail: `expected ${String(expected)} but resolved citation ${resolved}` };
97
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The one hash of a draft document.
3
+ *
4
+ * The look-before-record rule rides two durable markers, and each marker holds a hash of the draft. A
5
+ * stamp writes a hash, and a compare reads one. Both call this one function, thus two serializations of one
6
+ * document cannot split.
7
+ *
8
+ * The function serializes the draft with sorted object keys, and it hashes the text with sha256. The sort
9
+ * makes the hash independent of key order. Thus two objects with the same values and a different key order
10
+ * give one hash, and a marker survives a round trip through storage.
11
+ */
12
+ import type { DraftDocument } from "./draft.js";
13
+ /** The sha256 hash of the draft, over a JSON serialization with sorted keys. */
14
+ export declare function computeDraftHash(document: DraftDocument): string;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The one hash of a draft document.
3
+ *
4
+ * The look-before-record rule rides two durable markers, and each marker holds a hash of the draft. A
5
+ * stamp writes a hash, and a compare reads one. Both call this one function, thus two serializations of one
6
+ * document cannot split.
7
+ *
8
+ * The function serializes the draft with sorted object keys, and it hashes the text with sha256. The sort
9
+ * makes the hash independent of key order. Thus two objects with the same values and a different key order
10
+ * give one hash, and a marker survives a round trip through storage.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ /**
14
+ * Give back a copy of a value with each object key in sorted order, at every depth.
15
+ *
16
+ * An array keeps its order, because the order of an array is data. An object gets its keys in one canonical
17
+ * order, thus the serialization does not depend on the order that a producer wrote the keys.
18
+ */
19
+ function canonicalize(value) {
20
+ if (Array.isArray(value)) {
21
+ return value.map(canonicalize);
22
+ }
23
+ if (value !== null && typeof value === "object") {
24
+ const sorted = {};
25
+ for (const key of Object.keys(value).sort()) {
26
+ sorted[key] = canonicalize(value[key]);
27
+ }
28
+ return sorted;
29
+ }
30
+ return value;
31
+ }
32
+ /** The sha256 hash of the draft, over a JSON serialization with sorted keys. */
33
+ export function computeDraftHash(document) {
34
+ const canonical = JSON.stringify(canonicalize(document));
35
+ return createHash("sha256").update(canonical).digest("hex");
36
+ }
@@ -4,18 +4,8 @@
4
4
  * assertion rules without a host.
5
5
  */
6
6
  import { err, ok } from "neverthrow";
7
+ import { asFiniteNumber, cellMatchesFilterValue, checkCitationAssertion, checkValueAssertion } from "./assert-rules.js";
7
8
  import { columnsHeldByNoRow, fileTypeHoldsNoCell, snapshotEntry, } from "./reference-resolver.js";
8
- /**
9
- * The relative epsilon of a value match with no authored tolerance.
10
- *
11
- * Float arithmetic shifts a computed value in its last bits. For example, `0.3 - 0.1` gives
12
- * 0.19999999999999998, and exact equality would report that noise as a fabrication.
13
- *
14
- * The epsilon absorbs the noise and nothing else. It is far below the difference that a rounded figure
15
- * makes, thus an author who writes 0.05 against a true 0.0499 still fails. An author who wants a rounded
16
- * figure to pass states a `tolerance`, which is the one place where the intent belongs.
17
- */
18
- const RELATIVE_EPSILON = 1e-9;
19
9
  /** Build an `Err` that carries the unresolved reference. The `detail` key is present only when there is a detail to carry. */
20
10
  function fail(reference, reason, detail) {
21
11
  const failure = detail !== undefined ? { reference, reason, detail } : { reference, reason };
@@ -25,65 +15,6 @@ function fail(reference, reason, detail) {
25
15
  function describeReference(reference) {
26
16
  return `artifact value at ${reference.path} column ${reference.locator.column}`;
27
17
  }
28
- /**
29
- * Read an authored value or a resolved cell as a finite number.
30
- *
31
- * A text-backed artifact such as a CSV holds every cell as a string, thus a numeric column arrives as
32
- * `"0.01"` and not as `0.01`. A comparison that respects the JavaScript type would fail an author who
33
- * states the number, and the arithmetic of a derivation could never run over a real CSV.
34
- *
35
- * The parse reads the whole string, thus `"12 genes"` stays text and never reads as 12. `Number` accepts
36
- * the exponent form that a p-value uses, for example `"1.2e-45"`. A blank string is not a number, because
37
- * `Number("")` gives 0 and an empty cell must never read as zero.
38
- */
39
- function asFiniteNumber(value) {
40
- if (typeof value === "number") {
41
- return Number.isFinite(value) ? value : undefined;
42
- }
43
- if (value.trim() === "") {
44
- return undefined;
45
- }
46
- const parsed = Number(value);
47
- return Number.isFinite(parsed) ? parsed : undefined;
48
- }
49
- /** Name a value with its type, so that a failed match never reads as two identical values. */
50
- function describeValue(value) {
51
- return typeof value === "string" ? `the string ${JSON.stringify(value)}` : `the number ${value}`;
52
- }
53
- /**
54
- * Match a resolved value against an authored one.
55
- *
56
- * The comparison is numeric whenever both sides read as a finite number, thus the CSV cell `"0.01"`
57
- * matches the number 0.01 that an author states. An authored tolerance is an absolute difference, and it
58
- * is the author's own statement of how close is close enough. With no tolerance the comparison is
59
- * relative, because an exact match would fail on the float noise of a computed value. Any other pair
60
- * matches on exact equality.
61
- */
62
- function valuesMatch(expected, actual, tolerance) {
63
- const expectedNumber = asFiniteNumber(expected);
64
- const actualNumber = asFiniteNumber(actual);
65
- if (expectedNumber !== undefined && actualNumber !== undefined) {
66
- if (tolerance !== undefined) {
67
- return Math.abs(expectedNumber - actualNumber) <= tolerance;
68
- }
69
- return Math.abs(expectedNumber - actualNumber) <= RELATIVE_EPSILON * Math.max(1, Math.abs(expectedNumber), Math.abs(actualNumber));
70
- }
71
- return expected === actual;
72
- }
73
- /** Match a resolved scalar against the authored value, under the authored tolerance. */
74
- function checkValueAssertion(reference, expected, tolerance, resolved) {
75
- if (expected === undefined || valuesMatch(expected, resolved, tolerance)) {
76
- return undefined;
77
- }
78
- return { reference, reason: "assertion-failed", detail: `expected ${describeValue(expected)} but resolved ${describeValue(resolved)}` };
79
- }
80
- /** Match the resolved citation key against the authored value. The key carries its `idKind:` prefix. */
81
- function checkCitationAssertion(reference, expected, resolved) {
82
- if (expected === undefined || expected === resolved) {
83
- return undefined;
84
- }
85
- return { reference, reason: "assertion-failed", detail: `expected ${String(expected)} but resolved citation ${resolved}` };
86
- }
87
18
  function resolveArtifactValue(reference, snapshot) {
88
19
  const artifact = snapshotEntry(snapshot, reference.path);
89
20
  if (artifact === undefined) {
@@ -111,7 +42,7 @@ function resolveArtifactValue(reference, snapshot) {
111
42
  }
112
43
  else if (locator.rowFilter !== undefined) {
113
44
  const filter = locator.rowFilter;
114
- const matches = rows.filter((row) => row[filter.column] === filter.value);
45
+ const matches = rows.filter((row) => cellMatchesFilterValue(row[filter.column], filter.value));
115
46
  if (matches.length === 0) {
116
47
  return fail(reference, "locator-out-of-range", `no row where ${filter.column} equals ${String(filter.value)}`);
117
48
  }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The production realization of the reference resolver. It reads the pinned artifact on disk, and it
3
+ * gives the value that the belief points at.
4
+ *
5
+ * The realization holds three layers. Layer one is identity: membership through `snapshotEntry`, and a
6
+ * streamed hash compare with `computeSha256File`. A hash mismatch fails before any parse. Layer two is
7
+ * the host fast path: an in-process parse of a CSV, a TSV, a JSON, or a parquet file at or under the cap.
8
+ * Layer three is the fall-through: an over-cap file or a host parse fault goes to the extraction arm. On any
9
+ * doubt the read falls through, thus correctness never depends on the host parser. An extension that names
10
+ * no tabular format refuses here, because the host decides the format for both arms.
11
+ *
12
+ * A read of the bytes is the expensive part, thus an optional `ArtifactReadCache` outlives one resolver. It
13
+ * holds the streamed hash and the extracted rows against a stat signature. Two tool calls of one analysis
14
+ * share one cache, and each call still binds its own extraction arm with its own auth.
15
+ *
16
+ * The realization calls the shared assert functions in `assert-rules.ts`, and it mirrors the locator walk
17
+ * and the reason choices of `fixture-resolver.ts`. Thus one semantics exists, and the fixture stays the
18
+ * executable specification of the value tier. The two realizations are substitutable behind the seam.
19
+ */
20
+ import { type ReferenceResolver } from "./reference-resolver.js";
21
+ /**
22
+ * One artifact that the host fast path could not read, addressed for the extraction arm. The `hash` pins
23
+ * the exact bytes, thus the arm reads the same artifact that the identity layer verified.
24
+ *
25
+ * `format` carries the reader that the host decided. The arm never derives a reader from the extension,
26
+ * thus the host arm and the sandbox arm can never read one file two ways.
27
+ */
28
+ export interface ExtractionRequest {
29
+ readonly path: string;
30
+ readonly hash: string;
31
+ readonly format: TabularFormat;
32
+ }
33
+ /** The rows that the extraction arm read for one artifact. Downstream the rows share the host path logic. */
34
+ export interface ExtractionArtifact {
35
+ readonly rows: Array<Record<string, string | number>>;
36
+ }
37
+ /**
38
+ * The out-of-process read arm. One `extract` call covers every fall-through artifact of one document
39
+ * pass. The result maps each requested path to its rows. A path that the arm could not read is absent
40
+ * from the map, and the reference at that path fails as `unreadable-artifact`.
41
+ *
42
+ * The arm is a seam, thus a test stubs it. While no realization is wired, a fall-through reference fails
43
+ * with `extraction-unavailable`, and the detail names the absent arm.
44
+ */
45
+ export interface ExtractionArm {
46
+ extract(requests: readonly ExtractionRequest[]): Promise<ReadonlyMap<string, ExtractionArtifact>>;
47
+ }
48
+ /**
49
+ * The disk facts of one artifact that outlive one resolver.
50
+ *
51
+ * `signature` states the bytes that the pass read: the size, the modification time, and the inode. A later
52
+ * pass compares its own stat against it before it trusts anything here, thus a file that changed on disk is
53
+ * read again. `rows` is present only for a clean read, because a read fault is transient and it must not
54
+ * stick to the analysis.
55
+ */
56
+ export interface ArtifactRead {
57
+ readonly signature: string;
58
+ readonly hash: string;
59
+ readonly rows?: Array<Record<string, string | number>>;
60
+ }
61
+ /**
62
+ * The read cache of one analysis. It holds what a read of the bytes costs: the streamed hash, and the rows
63
+ * of a file that the host could not parse in process. Two resolvers of one analysis share one cache, thus a
64
+ * preview and the record that follows it hash one unchanged file one time and start one container.
65
+ *
66
+ * The cache holds no authorization and no session value. Each resolver still binds its own extraction arm
67
+ * over the auth of its own call.
68
+ */
69
+ export interface ArtifactReadCache {
70
+ get(absolutePath: string): ArtifactRead | undefined;
71
+ set(absolutePath: string, read: ArtifactRead): void;
72
+ }
73
+ /** The read caches that the host process holds, one for each recent analysis. */
74
+ export interface ArtifactReadStore {
75
+ forAnalysis(analysisId: string): ArtifactReadCache;
76
+ }
77
+ /**
78
+ * Make the read store of the report path. One host process serves many analyses, thus the store bounds both
79
+ * dimensions: how many analyses it holds, and how many artifacts one analysis holds. A dropped entry costs
80
+ * one read again, and it is never wrong.
81
+ */
82
+ export declare function createArtifactReadStore(): ArtifactReadStore;
83
+ /**
84
+ * The construction deps of the production resolver.
85
+ *
86
+ * `workspaceRoot` and `analysisId` contain the untrusted snapshot path through `resolveWorkspacePath`,
87
+ * the same containment as the preview tool. `cap` gates the cell extraction, and it defaults to 16 MiB.
88
+ * `extractionArm` is optional, because no sandbox realization is wired yet. `readCache` is optional, thus a
89
+ * resolver with no cache reads every artifact itself and its reads die with it.
90
+ */
91
+ export interface ProductionResolverDeps {
92
+ readonly workspaceRoot: string;
93
+ readonly analysisId: string;
94
+ readonly cap?: number;
95
+ readonly extractionArm?: ExtractionArm;
96
+ readonly readCache?: ArtifactReadCache;
97
+ }
98
+ /**
99
+ * The tabular formats that the host fast path reads. The host decides which one a file is, one time, and it
100
+ * carries the decision to the extraction arm. Another extension names no format at all.
101
+ */
102
+ export type TabularFormat = "csv" | "tsv" | "json" | "parquet";
103
+ /**
104
+ * Read one value as a cell, or drop it.
105
+ *
106
+ * A parquet integer column decodes as a `bigint`, thus the coercion narrows it to a number. A `null` or an
107
+ * absent value is not a cell, thus it drops and the row omits the key. The fixture row omits an empty cell
108
+ * the same way. An object or an array is structural doubt, thus it drops too and the format falls through
109
+ * where the caller detects the loss.
110
+ *
111
+ * A non-finite number is not a cell either. A parquet float column holds a `NaN` or an `Infinity`, thus the
112
+ * coercion drops it and the row omits the key. The extraction script (`tasks/extract-values-script.ts`)
113
+ * drops the same value to `None`, thus the host arm and the sandbox arm read the same bytes the same way.
114
+ */
115
+ export declare function coerceCell(value: unknown): string | number | undefined;
116
+ /**
117
+ * Make the production resolver over the workspace root and the optional extraction arm.
118
+ *
119
+ * The resolver holds a per-pass cache. `prepare` groups the references by artifact, reads each file one
120
+ * time, and fills the cache. `resolve` then answers from the cache. A `resolve` with no prior `prepare`
121
+ * still answers, and it writes what it read into the same cache. Thus the cost is one read for each
122
+ * artifact on both paths, and never one read for each reference.
123
+ */
124
+ export declare function createProductionResolver(deps: ProductionResolverDeps): ReferenceResolver;