From f4fd7c4801312df52ca217c04ebc60ef99b7eb65 Mon Sep 17 00:00:00 2001 From: kikashy Date: Thu, 22 Oct 2026 15:23:39 -0400 Subject: [PATCH 1/4] Define dialect data quality and typed research results Add closed per-target conversions, explicit multi-source reconciliation, quality findings, and preparation lineage so dialects own deterministic data quality. Define typed research result contracts, versioned acquisition-source profiles, retained execution evidence, and offline replay rules. Keep the RFC, companion text, both readers, and hand-written conformance cases aligned. Co-Authored-By: Codex Signed-off-by: kikashy --- CHANGELOG.md | 5 + conformance/dialect/README.md | 11 + rfcs/0018-dialect.md | 131 ++++++- spec/dialect.md | 113 +++++- tests/test_dialect.py | 121 ++++++ tools/dialect-verify-ts/src/forms.ts | 216 +++++++++-- tools/dialect-verify-ts/src/prepare.ts | 156 +++++++- tools/dialect-verify-ts/test/units.test.ts | 133 ++++++- tools/dialect_verify.py | 406 +++++++++++++++++++-- 9 files changed, 1210 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff49ad3..446a7d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ here. ### Added +- Dialect-owned data quality and typed research results (#170, #169): six closed deterministic + conversions with recorded failures, three explicit reconciliation modes, presence/type/domain + findings surfaced as unknown facts, transformation lineage, typed research result states, + versioned acquisition-source profiles, resolved execution settings and retained response bytes. + Replay uses retained bytes without calling a model or the web. - Document ceiling profiles (#232): default `base` and declared `extended`, a shared ceiling table, explicit unsupported-profile refusal, capability discovery and byte identity. Schemas retain base bounds; profile conformance covers negotiation. diff --git a/conformance/dialect/README.md b/conformance/dialect/README.md index d0d7870..7f576da 100644 --- a/conformance/dialect/README.md +++ b/conformance/dialect/README.md @@ -64,6 +64,17 @@ Runner's first: the Runner calls them "hand-maintained normative data", and says The links in the Runner's README are relative to the Runner's repository, and do not resolve here; the repository's link check passes over `vectors/` for that reason. +## Companion hand-written cases + +[`quality-vectors.json`](quality-vectors.json) and +[`research-result-vectors.json`](research-result-vectors.json) are specification-authored staged +cases for the 2026-10-22 amendment, not Runner recordings and not entries in the Runner-derived +manifest. The first covers every conversion kind, conversion failure, reconciliation mode and +quality finding, including go-to-market and work-item examples. The second covers the typed result +contract, every observation state, versioned acquisition-source declarations, resolved execution +settings and retained bytes. Both readers consume these same files; paired reader tests add the +empty, missing and one-over-bound mutations. No model or web request was made to write either file. + ## What a vector holds, and the answer a verifier gives Each vector is a directory. Of its files, a preparation verifier reads: diff --git a/rfcs/0018-dialect.md b/rfcs/0018-dialect.md index 0e648b2..a35164d 100644 --- a/rfcs/0018-dialect.md +++ b/rfcs/0018-dialect.md @@ -14,7 +14,8 @@ and what each research profile serves, issue #195; the class a Drive selected-file takes, issue #197, option A), 2026-10-17 (a page endpoint constraint, issue #199, direction A, round 15), 2026-10-18 (traversal spellings in a constrained page path, issue #214, - option C, round 16) + option C, round 16), 2026-10-22 (dialect-owned conversions, reconciliation and quality findings, + issue #170; typed research results, source profiles and retained execution evidence, issue #169) - Issue: [#141](https://github.com/Judgment-Pack/judgment-pack-spec/issues/141); the direction it serves is [#142](https://github.com/Judgment-Pack/judgment-pack-spec/issues/142) @@ -939,6 +940,27 @@ One vector is answered otherwise than `verify-run` answers it, by design: `disposition-differs-from-audit` alters the Runner's export, not the preparation or the record (Conformance). +## Amendment of 2026-10-22: data quality and typed research results + +The maintainer accepted issues #170 and #169 as proposed. This amendment makes deterministic +conversion, multi-observation reconciliation and presence/type/domain quality findings part of a +target's declared read, with every rule in the dialect digest and every application in preparation +lineage. The closed conversion set is intentionally not a scripting surface; failures yield no +coerced fact. `precedence`, `agree` and `first-present` are the only reconciliation modes, and +last-writer-wins remains forbidden by the target-ownership rule. + +The amendment also gives `research` the `results` typed-contract spelling and explicit observation +states, portable versioned acquisition-source profiles, and optional execution evidence containing +the resolved model/profile/settings and retained response bytes. Installation bindings continue to +own credentials, local connection ids, retries, concurrency, allowances and approvals. Replay +validates the retained bytes and applies the ordinary reads and targets without calling a model or +the web. These declarations affect the dialect digest; installation binding does not. + +The two repository readers and the hand-written staged quality vectors implement these forms. This +does not implement the Runner work tracked by Runner #86, nor the gateway acquisition capabilities +tracked by Gateway #275. It changes no Core qualification policy: an omitted quality target reaches +the pack as unknown and the pack's `onUnknown` decides the consequence. + ## Specification This section is the text proposed to land: the proposed companion document `spec/dialect.md`, its @@ -1392,6 +1414,27 @@ status is the caller's assertion. In particular, `stale` describes an observatio not an exception to receipt freshness: an expired retained receipt still fails as `acquisition-stale` (§5.2). +The observation vocabulary also admits the contract spellings `present`, `missing` and +`inaccessible`. `present` has the form and meaning of `found`. `missing`, `inaccessible` and +`stale` have exactly `status`, `cites` and a non-empty `reason`, and no `value` or `values`; +`missing` has the meaning of `not-found` with the source's reason made explicit. `conflicting` +keeps the typed `values` and their per-value citations above. A field declaration may add +`cardinality`, `one` or `many`; when absent it is `many` for `text-list` and `one` otherwise. +For the accepted typed-result spelling, the source writes `results` instead of `fields`, never +both. `results` is an ordered array of one to 64 closed objects of exactly `id`, `type`, `required`, +`description` and `cardinality`; `id` has the bounds and uniqueness of a field name; `type` is +`string`, `decimal`, `boolean` or `string-list`; `required` is boolean; `description` has the field +description form; and `cardinality` is `one` or `many`. The research record then carries `results` +instead of `fields`, with the same ids and the explicit states `present`, `missing`, `conflicting`, +`inaccessible` and `stale`. `present` carries `value` and citations; `conflicting` carries the typed +`values` with their citations; every other state carries a non-empty `reason` and no value. The +legacy `fields` spelling above remains readable in version 3 for the staged Runner vectors. Either +spelling is the research kind's typed result contract: identifier, description, exact type and +encoding, required presence and cardinality are declared before execution. A value or list outside +that declaration is `input-malformed`, never coerced. `one` carries one value of `type`; `many` +carries an array of zero to 64 values of `type`, including an array of string arrays for +`string-list` with `many`. + Absence is permitted only for an optional entry and for the conditional members just stated. `null` is never an entry or a value, including a conflicting value, list item or range endpoint. It may occur in a retained response's arbitrary result, where it remains evidence, not a typed @@ -1414,9 +1457,30 @@ take a `from` under `values`; that `from` is `input-malformed`, as in §6.4. Tha reconciliation's combined value and pointers. Deterministic sources keep their target declarations and gain no `fields` member. -`R.fields`, every status, value, observation and citation index, and `R.responses` in its order -are all in `R`'s `canon` bytes. The record's receipt commits all of them when present; without -one, fields as well as response membership and order are the caller's assertion. The preparation +**Acquisition sources and execution evidence.** A research source may declare +`acquisitionSources`, one to 16 closed objects of exactly `profile`, `version`, `purpose`, `scope` +and `freshness`. The first three are non-empty strings, `scope` is an object in the `canon` domain, +and `freshness` is an integer from zero to 31,536,000 seconds. These are portable, versioned +source-profile identities and request constraints, not vendor configuration. They enter the +research request and dialect digest. The installation binding supplies credentials, connection +ids, provider-specific settings, retries, concurrency, execution allowances and approvals; none +enters the published artifact or its digest. + +A research record using `results` has `execution`, a closed object of `resolvedSettings` and +`retainedBytes`; a legacy `fields` record may add the same object. +`resolvedSettings` is exactly non-empty `model`, `profile` and `version` strings and `settings`, an +object in the `canon` domain, recording what the installation actually resolved. `retainedBytes` +is an array of at most 64 closed `{"mediaType", "base64"}` objects and holds the response bytes +used to assemble the typed observations. The bytes, resolved settings, results, observation states +and responses are part of `R` and therefore of its receipt when it has one. Replay validates these +retained bytes and the result contract, then applies the ordinary read and target rules — including +§6.2a conversion, reconciliation and quality — without calling a model or the web. A requested +observation becomes a consumed fact only when the research read names its `/results/` state or +value and writes a target; a declaration alone writes no fact. + +`R.fields` or `R.results`, every status, value, observation and citation index, and `R.responses` +in its order are all in `R`'s `canon` bytes. The record's receipt commits all of them when present; without +one, results as well as response membership and order are the caller's assertion. The preparation commits the bound dialect, derived claims and receipt citations in §9's existing form; it gains no whole-record digest. A change to an unread, unreceipted field need not change that preparation. @@ -1830,6 +1894,65 @@ requirement twice is refused with the dialect. (No vector decides this.) The cla A value a copy writes MUST be in the `canon` domain, its numbers integers; otherwise `read-failed`. +#### 6.2a Target conversion, reconciliation and quality + +The dialect owns data quality. A fact entry in a copy may keep §6.2's exact two-member form, or it +is a closed **quality target** with `target`, `type`, `required`, and exactly one of `source` or +`observations`; it may also have `convert` and `domain`. `type` is `string`, `decimal`, `boolean` +or `date`. `required` is a boolean. A quality target with `source` names one pointer. With +`observations`, it names two to 16 closed `{"id", "source"}` objects, their non-empty ids distinct, +and has `reconcile`. This remains one target owned by one read: §8.1's target-overlap rule is +unchanged. + +`convert` is one of these closed deterministic rules, applied independently to every present +observation before reconciliation: + +| Kind | Other rule members | Result | +| --- | --- | --- | +| `decimal-from-text` | `decimalSeparator`, `.` or `,`; `thousandsSeparator`, a different string; `sign`, `none`, `minus` or `plus-minus`; `whitespace`, `reject` or `trim` | a Core §2.2 decimal string | +| `decimal-from-number` | none | the JSON number literal rendered without an exponent as the mathematically equal Core §2.2 decimal string | +| `boolean-from-text` | non-empty, disjoint `true` and `false` spelling arrays, each of at most 16 distinct strings | the corresponding JSON boolean | +| `trim` | none | the string without leading or trailing Unicode whitespace | +| `collapse-whitespace` | none | the trimmed string with each non-empty run of Unicode whitespace replaced by U+0020 | +| `date-from-text` | `format`, one of `YYYY-MM-DD`, `MM/DD/YYYY`, `DD/MM/YYYY` | the existing calendar date as `YYYY-MM-DD` | + +Here whitespace is exactly U+0009–U+000D, U+0020, U+0085, U+00A0, U+1680, +U+2000–U+200A, U+2028, U+2029, U+202F, U+205F and U+3000; no host-language +whitespace class extends that set. + +No other kind is admitted; an operation outside this table is upstream preparation, not a dialect +conversion. Missing stays missing and `null` stays `null`. A rule applied to another JSON type or +an invalid date has failure `invalid-encoding`; a decimal input that does not name a number has +`not-a-number`; a boolean spelling outside both lists has `out-of-domain`. A failed conversion +produces no candidate and never a coerced value. + +`reconcile` is exactly `{"mode": M}` for `agree` and `first-present`, or +`{"mode":"precedence","precedence":[I,...]}`. `precedence` lists every observation id exactly +once in its chosen order; the first present converted candidate in that list wins. +`first-present` uses observation declaration order. `agree` yields the common JSON-equal value when +all present candidates agree and otherwise records `conflict` and yields no value. An absent +candidate is not a conflicting value. `last-writer-wins` is not a mode and is `input-malformed`. +The reconciliation lineage records the mode, every present observation id, and the chosen id, +agreement or conflict as applicable. + +`domain` is exactly `{"in":[V,...]}`, one to 64 values in the `canon` domain, or +`{"decimalRange":{"min":D,"max":D}}`, inclusive, both endpoints Core §2.2 decimal strings and +the minimum no greater than the maximum. After conversion and reconciliation, a missing required +value records `missing-required`; a value not of `type` records `wrong-type`; a value outside +`domain` records `out-of-domain`. These are closed quality findings, as is `conflict`. Their target +is omitted from the facts document: to the pack it is an unknown fact, and the pack's `onUnknown`, +not the dialect, decides the consequence. Zero, `false` and the empty string are present. A `null` +candidate remains `null` and is neither silently repaired nor treated as missing. + +For every quality target, its lineage entry has `transformation`, exactly `applications` and +`findings`, and `reconciliation` when declared. Each application records its observation `id`, +pointer `source`, state `missing`, `present` or `failed`, the literal-preserving preparation-form +JSON text of the source value when present (with the acquisition retaining its source bytes), +the conversion `rule` when one applies, and exactly the `result` or closed `failure` code. Thus the +preparation records every application, successful or failed. The declaration, including every +rule, observation order, domain and reconciliation choice, is already in the read digest and the +dialect digest. No product may convert a value on the way to a pack outside this declared read. + #### 6.3 Rule `rule` is a **derivation rule**, version 1: the portable derivation rule the Runner vendors, cited in diff --git a/spec/dialect.md b/spec/dialect.md index 4df8224..3a7bd8b 100644 --- a/spec/dialect.md +++ b/spec/dialect.md @@ -79,6 +79,12 @@ a reading, and the old form now gives `preparation-differs`. The named-artifact amendment (RFC 0021, §2.1) adds exact format selectors `2.0.0` and `3.0.0` with independent artifact identities. It preserves both integer formats and all historical vectors. +The data-quality and typed-result amendment of 2026-10-22 (issues #170 and #169) adds §6.2a's +closed conversions, reconciliation modes and quality lineage, the `results` research contract, +versioned acquisition-source declarations and retained execution evidence. The hand-written +quality vectors are staged beside, but are not recordings from, the Runner vectors. Runner #86 +and Gateway #275 remain implementation work outside this companion change. + **Its sources.** Where the Runner's words define the format, this document keeps them; where they describe the Runner rather than the format, it says what the format is. @@ -562,6 +568,27 @@ status is the caller's assertion. In particular, `stale` describes an observatio not an exception to receipt freshness: an expired retained receipt still fails as `acquisition-stale` (§5.2). +The observation vocabulary also admits the contract spellings `present`, `missing` and +`inaccessible`. `present` has the form and meaning of `found`. `missing`, `inaccessible` and +`stale` have exactly `status`, `cites` and a non-empty `reason`, and no `value` or `values`; +`missing` has the meaning of `not-found` with the source's reason made explicit. `conflicting` +keeps the typed `values` and their per-value citations above. A field declaration may add +`cardinality`, `one` or `many`; when absent it is `many` for `text-list` and `one` otherwise. +For the accepted typed-result spelling, the source writes `results` instead of `fields`, never +both. `results` is an ordered array of one to 64 closed objects of exactly `id`, `type`, `required`, +`description` and `cardinality`; `id` has the bounds and uniqueness of a field name; `type` is +`string`, `decimal`, `boolean` or `string-list`; `required` is boolean; `description` has the field +description form; and `cardinality` is `one` or `many`. The research record then carries `results` +instead of `fields`, with the same ids and the explicit states `present`, `missing`, `conflicting`, +`inaccessible` and `stale`. `present` carries `value` and citations; `conflicting` carries the typed +`values` with their citations; every other state carries a non-empty `reason` and no value. The +legacy `fields` spelling above remains readable in version 3 for the staged Runner vectors. Either +spelling is the research kind's typed result contract: identifier, description, exact type and +encoding, required presence and cardinality are declared before execution. A value or list outside +that declaration is `input-malformed`, never coerced. `one` carries one value of `type`; `many` +carries an array of zero to 64 values of `type`, including an array of string arrays for +`string-list` with `many`. + Absence is permitted only for an optional entry and for the conditional members just stated. `null` is never an entry or a value, including a conflicting value, list item or range endpoint. It may occur in a retained response's arbitrary result, where it remains evidence, not a typed @@ -584,9 +611,30 @@ take a `from` under `values`; that `from` is `input-malformed`, as in §6.4. Tha reconciliation's combined value and pointers. Deterministic sources keep their target declarations and gain no `fields` member. -`R.fields`, every status, value, observation and citation index, and `R.responses` in its order -are all in `R`'s `canon` bytes. The record's receipt commits all of them when present; without -one, fields as well as response membership and order are the caller's assertion. The preparation +**Acquisition sources and execution evidence.** A research source may declare +`acquisitionSources`, one to 16 closed objects of exactly `profile`, `version`, `purpose`, `scope` +and `freshness`. The first three are non-empty strings, `scope` is an object in the `canon` domain, +and `freshness` is an integer from zero to 31,536,000 seconds. These are portable, versioned +source-profile identities and request constraints, not vendor configuration. They enter the +research request and dialect digest. The installation binding supplies credentials, connection +ids, provider-specific settings, retries, concurrency, execution allowances and approvals; none +enters the published artifact or its digest. + +A research record using `results` has `execution`, a closed object of `resolvedSettings` and +`retainedBytes`; a legacy `fields` record may add the same object. +`resolvedSettings` is exactly non-empty `model`, `profile` and `version` strings and `settings`, an +object in the `canon` domain, recording what the installation actually resolved. `retainedBytes` +is an array of at most 64 closed `{"mediaType", "base64"}` objects and holds the response bytes +used to assemble the typed observations. The bytes, resolved settings, results, observation states +and responses are part of `R` and therefore of its receipt when it has one. Replay validates these +retained bytes and the result contract, then applies the ordinary read and target rules — including +§6.2a conversion, reconciliation and quality — without calling a model or the web. A requested +observation becomes a consumed fact only when the research read names its `/results/` state or +value and writes a target; a declaration alone writes no fact. + +`R.fields` or `R.results`, every status, value, observation and citation index, and `R.responses` +in its order are all in `R`'s `canon` bytes. The record's receipt commits all of them when present; without +one, results as well as response membership and order are the caller's assertion. The preparation commits the bound dialect, derived claims and receipt citations in §9's existing form; it gains no whole-record digest. A change to an unread, unreceipted field need not change that preparation. @@ -1000,6 +1048,65 @@ requirement twice is refused with the dialect. (No vector decides this.) The cla A value a copy writes MUST be in the `canon` domain, its numbers integers; otherwise `read-failed`. +### 6.2a Target conversion, reconciliation and quality + +The dialect owns data quality. A fact entry in a copy may keep §6.2's exact two-member form, or it +is a closed **quality target** with `target`, `type`, `required`, and exactly one of `source` or +`observations`; it may also have `convert` and `domain`. `type` is `string`, `decimal`, `boolean` +or `date`. `required` is a boolean. A quality target with `source` names one pointer. With +`observations`, it names two to 16 closed `{"id", "source"}` objects, their non-empty ids distinct, +and has `reconcile`. This remains one target owned by one read: §8.1's target-overlap rule is +unchanged. + +`convert` is one of these closed deterministic rules, applied independently to every present +observation before reconciliation: + +| Kind | Other rule members | Result | +| --- | --- | --- | +| `decimal-from-text` | `decimalSeparator`, `.` or `,`; `thousandsSeparator`, a different string; `sign`, `none`, `minus` or `plus-minus`; `whitespace`, `reject` or `trim` | a Core §2.2 decimal string | +| `decimal-from-number` | none | the JSON number literal rendered without an exponent as the mathematically equal Core §2.2 decimal string | +| `boolean-from-text` | non-empty, disjoint `true` and `false` spelling arrays, each of at most 16 distinct strings | the corresponding JSON boolean | +| `trim` | none | the string without leading or trailing Unicode whitespace | +| `collapse-whitespace` | none | the trimmed string with each non-empty run of Unicode whitespace replaced by U+0020 | +| `date-from-text` | `format`, one of `YYYY-MM-DD`, `MM/DD/YYYY`, `DD/MM/YYYY` | the existing calendar date as `YYYY-MM-DD` | + +Here whitespace is exactly U+0009–U+000D, U+0020, U+0085, U+00A0, U+1680, +U+2000–U+200A, U+2028, U+2029, U+202F, U+205F and U+3000; no host-language +whitespace class extends that set. + +No other kind is admitted; an operation outside this table is upstream preparation, not a dialect +conversion. Missing stays missing and `null` stays `null`. A rule applied to another JSON type or +an invalid date has failure `invalid-encoding`; a decimal input that does not name a number has +`not-a-number`; a boolean spelling outside both lists has `out-of-domain`. A failed conversion +produces no candidate and never a coerced value. + +`reconcile` is exactly `{"mode": M}` for `agree` and `first-present`, or +`{"mode":"precedence","precedence":[I,...]}`. `precedence` lists every observation id exactly +once in its chosen order; the first present converted candidate in that list wins. +`first-present` uses observation declaration order. `agree` yields the common JSON-equal value when +all present candidates agree and otherwise records `conflict` and yields no value. An absent +candidate is not a conflicting value. `last-writer-wins` is not a mode and is `input-malformed`. +The reconciliation lineage records the mode, every present observation id, and the chosen id, +agreement or conflict as applicable. + +`domain` is exactly `{"in":[V,...]}`, one to 64 values in the `canon` domain, or +`{"decimalRange":{"min":D,"max":D}}`, inclusive, both endpoints Core §2.2 decimal strings and +the minimum no greater than the maximum. After conversion and reconciliation, a missing required +value records `missing-required`; a value not of `type` records `wrong-type`; a value outside +`domain` records `out-of-domain`. These are closed quality findings, as is `conflict`. Their target +is omitted from the facts document: to the pack it is an unknown fact, and the pack's `onUnknown`, +not the dialect, decides the consequence. Zero, `false` and the empty string are present. A `null` +candidate remains `null` and is neither silently repaired nor treated as missing. + +For every quality target, its lineage entry has `transformation`, exactly `applications` and +`findings`, and `reconciliation` when declared. Each application records its observation `id`, +pointer `source`, state `missing`, `present` or `failed`, the literal-preserving preparation-form +JSON text of the source value when present (with the acquisition retaining its source bytes), +the conversion `rule` when one applies, and exactly the `result` or closed `failure` code. Thus the +preparation records every application, successful or failed. The declaration, including every +rule, observation order, domain and reconciliation choice, is already in the read digest and the +dialect digest. No product may convert a value on the way to a pack outside this declared read. + ### 6.3 Rule `rule` is a **derivation rule**, version 1: the portable derivation rule the Runner vendors, cited in diff --git a/tests/test_dialect.py b/tests/test_dialect.py index c900411..068ee86 100644 --- a/tests/test_dialect.py +++ b/tests/test_dialect.py @@ -2686,6 +2686,127 @@ def answer(change: Any = None, own: bool = False) -> str: self.assertEqual("refusal:input-malformed", answer(lambda r: r.pop("salts"), own=True)) +class DataQualityVectorTests(unittest.TestCase): + def test_the_hand_written_quality_vectors(self) -> None: + document = READER.read_document((DIALECT / "quality-vectors.json").read_bytes(), "quality vectors") + for row in document["cases"]: + with self.subTest(vector=row["id"]): + try: + READER.count_bounds( + {"case": {}, "sources": [{"read": {"copy": {"facts": [row["entry"]]}}}]}, + {}, + ) + read = READER.check_read( + {"copy": {"facts": [row["entry"]], "evidence": []}}, {}, row["id"] + ) + transformations: dict[str, Any] = {} + claim = READER.copy_claim(read.copy, row["input"], transformations) + except READER.Refused as refusal: + self.assertEqual(row.get("expectedRefusal"), refusal.name) + continue + self.assertNotIn("expectedRefusal", row) + value = READER.resolve(claim["facts"], "/value") + if "expected" in row: + self.assertTrue(READER.json_equal(row["expected"], value)) + else: + self.assertIs(READER.ABSENT, value) + transformation = transformations["/value"] + if "expectedFinding" in row: + self.assertIn({"code": row["expectedFinding"]}, transformation["findings"]) + if "expectedFailure" in row: + self.assertEqual(row["expectedFailure"], transformation["applications"][0]["failure"]["code"]) + if "expectedChosen" in row: + self.assertEqual(row["expectedChosen"], transformation["reconciliation"]["chosen"]) + + def test_the_hand_written_research_result_vectors(self) -> None: + document = READER.read_document((DIALECT / "research-result-vectors.json").read_bytes(), "research vectors") + dialect = READER.parse_dialect(document["dialect"]) + research = dialect.sources[0].research + self.assertIsNotNone(research) + self.assertEqual("results", research.result_member) + self.assertEqual(["/results/summary/status"], dialect.sources[0].read.candidates[("fact", "/research/state")]) + for row in document["cases"]: + with self.subTest(vector=row["id"]): + try: + READER.check_acquisition_form("research", {"response": {"result": row["record"]}}, research) + except READER.Refused as refusal: + self.assertEqual(row["expected"], refusal.name) + else: + self.assertEqual("accepted", row["expected"]) + transformations: dict[str, Any] = {} + claim = READER.copy_claim(dialect.sources[0].read.copy, row["record"], transformations) + self.assertEqual("missing", READER.resolve(claim["facts"], "/research/state")) + self.assertEqual({"kind": "trim"}, transformations["/research/state"]["applications"][0]["rule"]) + + def test_research_result_refusal_mutations(self) -> None: + document = READER.read_document((DIALECT / "research-result-vectors.json").read_bytes(), "research vectors") + for name, change in { + "empty contract": lambda d: d["sources"][0].__setitem__("results", []), + "missing cardinality": lambda d: d["sources"][0]["results"][0].pop("cardinality"), + }.items(): + dialect = copy.deepcopy(document["dialect"]) + change(dialect) + with self.subTest(mutation=name), self.assertRaisesRegex(READER.Refused, "input-malformed"): + READER.parse_dialect(dialect) + + dialect = copy.deepcopy(document["dialect"]) + seed = dialect["sources"][0]["results"][0] + dialect["sources"][0]["results"] = [{**seed, "id": f"field-{n}"} for n in range(65)] + with self.assertRaisesRegex(READER.Refused, "input-over-bound"): + READER.count_bounds(dialect, {}) + + dialect = READER.parse_dialect(document["dialect"]) + research = dialect.sources[0].research + record = copy.deepcopy(document["cases"][0]["record"]) + record["execution"].pop("retainedBytes") + with self.assertRaisesRegex(READER.Refused, "input-malformed"): + READER.check_acquisition_form("research", {"response": {"result": record}}, research) + + for name, change in { + "missing execution": lambda r: r.pop("execution"), + "invalid retained bytes": lambda r: r["execution"]["retainedBytes"][0].__setitem__("base64", "!"), + }.items(): + record = copy.deepcopy(document["cases"][0]["record"]) + change(record) + with self.subTest(mutation=name), self.assertRaisesRegex(READER.Refused, "input-malformed"): + READER.check_acquisition_form("research", {"response": {"result": record}}, research) + + record = copy.deepcopy(document["cases"][0]["record"]) + record["execution"]["retainedBytes"] = [{"mediaType": "application/json", "base64": "e30="}] * 65 + acquisitions = {"research": {"response": {"result": record}}} + with self.assertRaisesRegex(READER.Refused, "input-over-bound"): + READER.count_bounds(document["dialect"], acquisitions) + + def test_semantic_declarations_change_the_dialect_digest(self) -> None: + document = READER.read_document((DIALECT / "research-result-vectors.json").read_bytes(), "research vectors") + base = document["dialect"] + variants = [] + for change in ( + lambda d: d["sources"][0]["results"][0].__setitem__("description", "A different contract."), + lambda d: d["sources"][0].__setitem__("question", "Use a different request."), + lambda d: d["sources"][0]["acquisitionSources"][0].__setitem__("version", "2"), + ): + changed = copy.deepcopy(base) + change(changed) + READER.parse_dialect(changed) + variants.append(changed) + + conversion = copy.deepcopy(base) + conversion["sources"][0]["read"]["copy"]["facts"][0] = { + "target": "/research/state", "source": "/results/summary/status", + "type": "string", "required": True, "convert": {"kind": "trim"}, + } + READER.parse_dialect(conversion) + changed_conversion = copy.deepcopy(conversion) + changed_conversion["sources"][0]["read"]["copy"]["facts"][0]["convert"] = {"kind": "collapse-whitespace"} + READER.parse_dialect(changed_conversion) + + digest = lambda d: READER.digest_of(READER.canon(d)) + for changed in variants: + self.assertNotEqual(digest(base), digest(changed)) + self.assertNotEqual(digest(conversion), digest(changed_conversion)) + + class DialectTextTests(unittest.TestCase): def test_the_rfcs_specification_is_the_companion_text(self) -> None: # RFC 0017's choice: the Specification section is the text proposed to land. Here the text diff --git a/tools/dialect-verify-ts/src/forms.ts b/tools/dialect-verify-ts/src/forms.ts index b68e927..d5b49ce 100644 --- a/tools/dialect-verify-ts/src/forms.ts +++ b/tools/dialect-verify-ts/src/forms.ts @@ -35,7 +35,8 @@ const VERSION_3_KINDS = new Map; acquisitionStatus: string }; export type Clause = { when: JObj; claim: ClaimForm; reason: string }; export type Rule = { parameters: Map; clauses: Clause[] }; @@ -68,6 +80,7 @@ export type Research = { model: string; request: [string, JVal][]; web: Map; + resultMember: "fields" | "results"; }; export type Calculation = { inputs: Map; tables: Map }; @@ -206,6 +219,7 @@ const characters = (s: string) => Array.from(s).length; * maxima; a value of another form is measured by nothing here. A decimal string is counted in characters. */ function overByType(v: JVal | undefined, type: string | undefined): boolean { if (v === undefined) return false; + if (type?.endsWith("[]")) return v.t === "arr" && (v.a.length > BOUNDS.listItems || v.a.some((x) => overByType(x, type.slice(0, -2)))); if (type === "text") return v.t === "str" && byteLength(v.v) > BOUNDS.textBytes; if (type === "text-list") return v.t === "arr" && (v.a.length > BOUNDS.listItems || v.a.some((x) => x.t === "str" && byteLength(x.v) > BOUNDS.itemBytes)); if (type === "decimal") return v.t === "str" && characters(v.v) > BOUNDS.decimalCharacters; @@ -227,17 +241,26 @@ function researchWithin(dialect: JVal, acquisitions: JVal, profiles: JVal | unde const kind = member(s, "kind"); if (kind?.t !== "str" || kind.v !== "research") continue; const types = new Map(); - const fields = member(s, "fields"); + const fields = member(s, "results") ?? member(s, "fields"); if (fields?.t === "arr") { if (fields.a.length > BOUNDS.fields) return false; for (const d of fields.a) { if (d.t !== "obj") continue; - const name = member(d, "name"); + const name = member(d, "id") ?? member(d, "name"); if (longer(name, BOUNDS.fieldNameBytes) || longer(member(d, "description"), BOUNDS.descriptionBytes)) return false; const type = member(d, "type"); - if (name?.t === "str" && !types.has(name.v) && type?.t === "str" && FIELD_TYPES.has(type.v)) types.set(name.v, type.v); + let declaredType = type?.t === "str" ? type.v : undefined; + if (member(s, "results") !== undefined) { + if (declaredType === "string") declaredType = "text"; + if (declaredType === "string-list") declaredType = "text-list"; + if (member(d, "cardinality")?.t === "str" && (member(d, "cardinality") as JStr).v === "many" && declaredType !== undefined) declaredType += "[]"; + } + if (name?.t === "str" && !types.has(name.v) && declaredType !== undefined + && (FIELD_TYPES.has(declaredType) || declaredType.endsWith("[]") && FIELD_TYPES.has(declaredType.slice(0, -2)))) types.set(name.v, declaredType); } } + const acquisitionSources = member(s, "acquisitionSources"); + if (acquisitionSources?.t === "arr" && acquisitionSources.a.length > BOUNDS.sources) return false; if (longer(member(s, "model"), BOUNDS.modelBytes)) return false; const evidence = member(s, "evidence"); if (evidence?.t === "arr" && evidence.a.some((x) => longer(x, BOUNDS.evidenceBytes))) return false; @@ -265,7 +288,10 @@ function researchWithin(dialect: JVal, acquisitions: JVal, profiles: JVal | unde for (const [name, a] of acquisitions.m) { const types = declared.get(name); if (types === undefined) continue; - const answers = member(member(member(a, "response"), "result"), "fields"); + const record = member(member(a, "response"), "result"); + const answers = member(record, "results") ?? member(record, "fields"); + const retainedBytes = member(member(record, "execution"), "retainedBytes"); + if (retainedBytes?.t === "arr" && retainedBytes.a.length > BOUNDS.retainedResponses) return false; if (answers?.t !== "obj") continue; if (answers.m.size > BOUNDS.fields) return false; // every answer's name, declared or not, before any entry is inspected or its declaration looked up @@ -303,16 +329,21 @@ export function countsWithin(dialect: JVal, acquisitions: JVal, profiles?: JVal, if (nCase + 1 > BOUNDS.scopeParameters) return false; let facts = 0; let evidence = 0; - const copyTargets = (copy: JVal | undefined, f: Set, e: Set) => { + const copyTargets = (copy: JVal | undefined, f: Set, e: Set): boolean => { const cf = member(copy, "facts"); - if (cf?.t === "arr") for (const x of cf.a) { const t = member(x, "target"); if (t?.t === "str") f.add(t.v); } + if (cf?.t === "arr") for (const x of cf.a) { + const t = member(x, "target"); if (t?.t === "str") f.add(t.v); + const observations = member(x, "observations"); + if (observations?.t === "arr" && observations.a.length > BOUNDS.sources) return false; + } const ce = member(copy, "evidence"); if (ce?.t === "arr") for (const x of ce.a) { const r = member(x, "requirement"); if (r?.t === "str") e.add(r.v); } + return true; }; { const f = new Set(); const e = new Set(); - copyTargets(caseObj, f, e); + if (!copyTargets(caseObj, f, e)) return false; facts += f.size; evidence += e.size; } @@ -338,7 +369,7 @@ export function countsWithin(dialect: JVal, acquisitions: JVal, profiles?: JVal, if (unwrap?.t === "arr" && unwrap.a.length > BOUNDS.unwraps) return false; const f = new Set(); const e = new Set(); - copyTargets(member(read, "copy"), f, e); + if (!copyTargets(member(read, "copy"), f, e)) return false; const clauses = member(member(read, "rule"), "clauses"); if (clauses?.t === "arr") { if (clauses.a.length > BOUNDS.clauses) return false; @@ -390,8 +421,48 @@ type Scope = Map; function readCopy(v: JVal | undefined, what: string, arraysRequired: boolean, besides: string[] = []): CopyRead { const o = exact(v, what, arraysRequired ? ["facts", "evidence"] : [], arraysRequired ? besides : ["facts", "evidence", ...besides]); const facts = (o.m.has("facts") ? array(o.m.get("facts"), `${what}.facts`) : []).map((x, k) => { - const e = exact(x, `${what}.facts[${k}]`, ["target", "source"]); - return { target: factTarget(e.m.get("target"), "a copy's target"), source: pointer(e.m.get("source"), "a copy's source") }; + const w = `${what}.facts[${k}]`; + const e = object(x, w); + const target = factTarget(e.m.get("target"), "a copy's target"); + const extended = Array.from(e.m.keys()).some((n) => n !== "target" && n !== "source"); + if (!extended) { + exact(e, w, ["target", "source"]); + return { target, source: pointer(e.m.get("source"), "a copy's source") }; + } + exact(e, w, ["target", "type", "required"], ["source", "observations", "reconcile", "convert", "domain"]); + const hasSource = e.m.has("source"); + const hasObservations = e.m.has("observations"); + if (hasSource === hasObservations) malformed(`${w} has not exactly one of source and observations`); + const source = hasSource ? pointer(e.m.get("source"), `${w}.source`) : undefined; + let observations: { id: string; source: string }[] | undefined; + let reconcile: QualityFact["reconcile"]; + if (hasObservations) { + const raw = array(e.m.get("observations"), `${w}.observations`); + if (raw.length < 2) malformed(`${w}.observations has fewer than two entries`); + observations = raw.map((v, n) => { + const q = exact(v, `${w}.observations[${n}]`, ["id", "source"]); + return { id: nonEmpty(q.m.get("id"), `${w}.observations[${n}].id`), source: pointer(q.m.get("source"), `${w}.observations[${n}].source`) }; + }); + const ids = observations.map((q) => q.id); + if (new Set(ids).size !== ids.length) malformed(`${w}.observations repeats an id`); + const r = exact(e.m.get("reconcile"), `${w}.reconcile`, ["mode"], ["precedence"]); + const mode = string(r.m.get("mode"), `${w}.reconcile.mode`); + if (mode !== "precedence" && mode !== "agree" && mode !== "first-present") malformed(`${w}.reconcile.mode is unknown`); + const precedence = r.m.has("precedence") ? array(r.m.get("precedence"), `${w}.reconcile.precedence`).map((p, n) => string(p, `${w}.reconcile.precedence[${n}]`)) : undefined; + if (mode === "precedence" && (precedence === undefined || precedence.length !== ids.length + || new Set(precedence).size !== ids.length || precedence.some((id) => !ids.includes(id)))) { + malformed(`${w}.reconcile.precedence is not exactly the observation ids`); + } + if (mode !== "precedence" && precedence !== undefined) malformed(`${w}.reconcile has precedence for another mode`); + reconcile = { mode, precedence }; + } else if (e.m.has("reconcile")) malformed(`${w} reconciles one source`); + const qualityType = string(e.m.get("type"), `${w}.type`); + if (!["string", "decimal", "boolean", "date"].includes(qualityType)) malformed(`${w}.type is unknown`); + const required = e.m.get("required"); + if (required?.t !== "bool") malformed(`${w}.required is not a boolean`); + const convert = e.m.has("convert") ? readConversion(e.m.get("convert"), `${w}.convert`) : undefined; + const domain = e.m.has("domain") ? readDomain(e.m.get("domain"), `${w}.domain`) : undefined; + return { target, source, observations, reconcile, convert, qualityType: qualityType as QualityFact["qualityType"], required: required.v, domain }; }); const evidence = (o.m.has("evidence") ? array(o.m.get("evidence"), `${what}.evidence`) : []).map((x, k) => { const e = exact(x, `${what}.evidence[${k}]`, ["requirement", "source"]); @@ -405,6 +476,47 @@ function readCopy(v: JVal | undefined, what: string, arraysRequired: boolean, be return { facts, evidence }; } +/** Shared hand-written §6.2a vector entry, through the same closed-form reader as a dialect. */ +export function readQualityFact(entry: JVal, what = "quality vector"): CopyRead { + return readCopy(obj([["facts", arr([entry])], ["evidence", arr([])]]), what, true); +} + +function readConversion(v: JVal | undefined, what: string): JObj { + const o = object(v, what); + const kind = string(o.m.get("kind"), `${what}.kind`); + if (kind === "decimal-from-text") { + exact(o, what, ["kind", "decimalSeparator", "thousandsSeparator", "sign", "whitespace"]); + const decimal = string(o.m.get("decimalSeparator"), `${what}.decimalSeparator`); + const thousands = string(o.m.get("thousandsSeparator"), `${what}.thousandsSeparator`); + if (!['.', ','].includes(decimal) || thousands === decimal) malformed(`${what} has invalid separators`); + if (!["none", "minus", "plus-minus"].includes(string(o.m.get("sign"), `${what}.sign`))) malformed(`${what}.sign is unknown`); + if (!["reject", "trim"].includes(string(o.m.get("whitespace"), `${what}.whitespace`))) malformed(`${what}.whitespace is unknown`); + } else if (kind === "boolean-from-text") { + exact(o, what, ["kind", "true", "false"]); + const lists = ["true", "false"].map((name) => array(o.m.get(name), `${what}.${name}`).map((x, k) => string(x, `${what}.${name}[${k}]`))); + if (lists.some((xs) => xs.length < 1 || xs.length > 16 || new Set(xs).size !== xs.length) || lists[0].some((x) => lists[1].includes(x))) malformed(`${what} has invalid boolean spellings`); + } else if (kind === "date-from-text") { + exact(o, what, ["kind", "format"]); + if (!["YYYY-MM-DD", "MM/DD/YYYY", "DD/MM/YYYY"].includes(string(o.m.get("format"), `${what}.format`))) malformed(`${what}.format is unknown`); + } else if (["decimal-from-number", "trim", "collapse-whitespace"].includes(kind)) exact(o, what, ["kind"]); + else malformed(`${what}.kind is unknown`); + return o; +} + +function readDomain(v: JVal | undefined, what: string): JObj { + const o = object(v, what); + if (o.m.size !== 1) malformed(`${what} does not have exactly one domain form`); + if (o.m.has("in")) { + const values = array(o.m.get("in"), `${what}.in`); + if (values.length < 1 || values.length > 64 || values.some((x) => !inCanonDomain(x))) malformed(`${what}.in is invalid`); + } else if (o.m.has("decimalRange")) { + const r = exact(o.m.get("decimalRange"), `${what}.decimalRange`, ["min", "max"]); + for (const n of ["min", "max"]) if (!DECIMAL.test(string(r.m.get(n), `${what}.decimalRange.${n}`))) malformed(`${what}.decimalRange.${n} is not decimal`); + if (compareDecimals((r.m.get("min") as JStr).v, (r.m.get("max") as JStr).v) > 0) malformed(`${what}.decimalRange is reversed`); + } else malformed(`${what} has an unknown domain form`); + return o; +} + function readCondition(v: JVal | undefined, declared: Map, what: string): JObj { const o = object(v, what); const op = string(o.m.get("op"), `${what}.op`); @@ -690,7 +802,8 @@ export function readDialect(d: JVal): Dialect { } else if (kind === "research") { // §4.3, §4.4: published unbound, with none of the three binding members, or bound, with all three if (v === 2) malformed(`${what} names the version-3 kind ${kind} in a version-2 dialect`); - exact(so, what, [...base, ...RESEARCH_MEMBERS], ["parameters", ...RESEARCH_BINDING]); + exact(so, what, [...base, ...RESEARCH_MEMBERS], ["parameters", ...RESEARCH_BINDING, ...RESEARCH_OPTIONAL]); + if (so.m.has("fields") === so.m.has("results")) malformed(`${what} has not exactly one of fields and results`); const given = RESEARCH_BINDING.filter((k) => so.m.has(k)).length; if (given !== 0 && given !== RESEARCH_BINDING.length) malformed(`${what} has some but not all of profile, profileDigest and webProfiles`); } else { @@ -904,28 +1017,50 @@ function readResearch(so: JObj, scope: Scope, what: string): Research { object(so.m.get("constraints"), `${what}.constraints`); const model = nonEmpty(so.m.get("model"), `${what}.model`); array(so.m.get("evidence"), `${what}.evidence`).forEach((x, k) => nonEmpty(x, `${what}.evidence[${k}]`)); - const declarations = array(so.m.get("fields"), `${what}.fields`); + const resultMember = so.m.has("results") ? "results" : "fields"; + const declarations = array(so.m.get(resultMember), `${what}.${resultMember}`); if (declarations.length === 0) malformed(`${what}.fields declares no field`); const types = new Map(); const fields = declarations.map((x, k) => { const w = `${what}.fields[${k}]`; - const d = exact(x, w, ["name", "type", "required", "description"]); - const name = nonEmpty(d.m.get("name"), `${w}.name`); + const d = resultMember === "results" + ? exact(x, w, ["id", "type", "required", "description", "cardinality"]) + : exact(x, w, ["name", "type", "required", "description"], ["cardinality"]); + const name = nonEmpty(d.m.get(resultMember === "results" ? "id" : "name"), `${w}.${resultMember === "results" ? "id" : "name"}`); if (name === "case") malformed(`${w}.name is case`); if (types.has(name)) malformed(`${w}.name ${name} is given twice`); - const type = string(d.m.get("type"), `${w}.type`); - if (!FIELD_TYPES.has(type)) malformed(`${w}.type is not one of the six`); + let type = string(d.m.get("type"), `${w}.type`); + if (resultMember === "results") { + if (!["string", "decimal", "boolean", "string-list"].includes(type)) malformed(`${w}.type is outside the result contract`); + type = type === "string" ? "text" : type === "string-list" ? "text-list" : type; + if ((d.m.get("cardinality") as JStr).v === "many") type += "[]"; + } + if (!FIELD_TYPES.has(type) && !(type.endsWith("[]") && FIELD_TYPES.has(type.slice(0, -2)))) malformed(`${w}.type is not one of the six`); const required = d.m.get("required")!; if (required.t !== "bool") malformed(`${w}.required is not a boolean`); nonEmpty(d.m.get("description"), `${w}.description`); + if (d.m.has("cardinality") && !["one", "many"].includes(string(d.m.get("cardinality"), `${w}.cardinality`))) malformed(`${w}.cardinality is not one or many`); types.set(name, type); return { name, type, required: (required as { v: boolean }).v }; }); const capabilities = readCapabilities(so.m.get("capabilities"), `${what}.capabilities`); if (capabilities[0] !== "answer") malformed(`${what}.capabilities lacks answer, which is required`); - const request: [string, JVal][] = ["constraints", "model", "evidence", "fields", "capabilities"].map((k) => [k, so.m.get(k)!]); + if (so.m.has("acquisitionSources")) { + const sources = array(so.m.get("acquisitionSources"), `${what}.acquisitionSources`); + if (sources.length < 1 || sources.length > BOUNDS.sources) malformed(`${what}.acquisitionSources is not one to 16 entries`); + sources.forEach((x, k) => { + const w = `${what}.acquisitionSources[${k}]`; + const source = exact(x, w, ["profile", "version", "purpose", "scope", "freshness"]); + for (const name of ["profile", "version", "purpose"]) nonEmpty(source.m.get(name), `${w}.${name}`); + const scope = object(source.m.get("scope"), `${w}.scope`); + if (!inCanonDomain(scope)) malformed(`${w}.scope is outside the canon domain`); + integerIn(source.m.get("freshness"), 0, 31536000, `${w}.freshness`); + }); + } + const requestNames = ["constraints", "model", "evidence", resultMember, "capabilities", ...(so.m.has("acquisitionSources") ? ["acquisitionSources"] : [])]; + const request: [string, JVal][] = requestNames.map((k) => [k, so.m.get(k)!]); const web = so.m.has("webProfiles") ? readWebProfiles(so.m.get("webProfiles"), capabilities, `${what}.webProfiles`) : new Map(); - return { question, fields, types, capabilities, model, request, web }; + return { question, fields, types, capabilities, model, request, web, resultMember }; } /** A condition and every condition within it, at any depth. */ @@ -947,10 +1082,11 @@ const sameTokens = (a: string[], b: string[]) => a.length === b.length && a.ever function readResearchRead(v: JVal | undefined, scope: Scope, what: string, research: Research, earlier: Source[]): Read { if (v !== undefined && v.t === "obj" && v.m.has("sources")) return readReconciliation(v, scope, what, research, earlier); const read = readRead(v, scope, what); + const resultMember = research.resultMember; const readable = (p: string, taken: boolean) => { const t = tokens(p); - if (sameTokens(t, ["fields"])) return; - if (t.length >= 2 && t[0] === "fields" && research.types.has(t[1])) { + if (sameTokens(t, [resultMember])) return; + if (t.length >= 2 && t[0] === resultMember && research.types.has(t[1])) { const rest = t.slice(2); if (rest.length === 0 || rest[0] === "value" || sameTokens(rest, ["status"]) || sameTokens(rest, ["cites"])) return; if (rest.length === 3 && rest[0] === "values" && INDEX_TOKEN.test(rest[1]) && rest[2] === "value") { @@ -963,7 +1099,7 @@ function readResearchRead(v: JVal | undefined, scope: Scope, what: string, resea // An unwrap pointer takes the value read from where it points: this reader holds it as a from. for (const p of read.unwrap) readable(p, true); if (read.copy) { - for (const f of read.copy.facts) readable(f.source, true); + for (const f of read.copy.facts) for (const p of f.observations?.map((o) => o.source) ?? [f.source!]) readable(p, true); for (const e of read.copy.evidence) readable(e.source, true); } else { for (const c of leavesOf(read.rule!)) readable((c.m.get("field") as JStr).v, false); @@ -1233,6 +1369,11 @@ export function compareDecimals(a: string, b: string): number { /** §4.3: a value of a field's declared type in its exact encoding; `null` is never one. Maxima are phase 3's. */ function typed(v: JVal | undefined, type: string, what: string): void { let ok: boolean; + if (type.endsWith("[]")) { + if (v === undefined || v.t !== "arr" || v.a.length > BOUNDS.listItems) malformed(`${what} is not a value of the declared type ${type}`); + v.a.forEach((x, k) => typed(x, type.slice(0, -2), `${what}[${k}]`)); + return; + } if (v === undefined) ok = false; else if (type === "integer") ok = isInteger(v); else if (type === "decimal") ok = v.t === "str" && DECIMAL.test(v.v); @@ -1271,7 +1412,7 @@ function readAnswers(v: JVal | undefined, research: Research, responses: number, const type = research.types.get(name)!; const status = member(entry, "status"); const s = status?.t === "str" ? status.v : undefined; - if (s === "found") { + if (s === "found" || s === "present") { const e = exact(entry, w, ["status", "cites", "value"]); cites(e.m.get("cites"), responses, 1, `${w}.cites`); typed(e.m.get("value"), type, `${w}.value`); @@ -1288,9 +1429,11 @@ function readAnswers(v: JVal | undefined, research: Research, responses: number, }); const first = member(values[0], "value")!; if (values.every((x) => jsonEqual(first, member(x, "value")!))) malformed(`${w}.values has no two that differ`); - } else if (s === "not-found" || s === "stale") { - const e = exact(entry, w, ["status", "cites"]); + } else if (s === "not-found" || s === "missing" || s === "inaccessible" || s === "stale") { + const needsReason = research.resultMember === "results" && s !== "not-found"; + const e = exact(entry, w, needsReason ? ["status", "cites", "reason"] : ["status", "cites"]); cites(e.m.get("cites"), responses, 0, `${w}.cites`); + if (needsReason) nonEmpty(e.m.get("reason"), `${w}.reason`); } else { malformed(`${w} is not an entry of a known status`); } @@ -1318,7 +1461,9 @@ export function readAcquisitions(v: JVal, research: Map = new } const r = exact(response, `${what}.response`, ["result"], ["receipt", "salts"]); if (r.m.has("receipt") !== r.m.has("salts")) malformed(`${what}.response has one of receipt and salts`); - const record = exact(r.m.get("result"), `${what}.response.result`, ["fields", "responses"]); + const record = exact(r.m.get("result"), `${what}.response.result`, + recipe.resultMember === "results" ? [recipe.resultMember, "responses", "execution"] : [recipe.resultMember, "responses"], + recipe.resultMember === "results" ? [] : ["execution"]); const retainedResponses = array(record.m.get("responses"), `${what}.response.result.responses`); retainedResponses.forEach((x, k) => { const w = `${what}.response.result.responses[${k}]`; @@ -1331,7 +1476,22 @@ export function readAcquisitions(v: JVal, research: Map = new object(retained.m.get("receipt"), `${w}.receipt`); readSalts(retained.m.get("salts"), `${w}.salts`); }); - readAnswers(record.m.get("fields"), recipe, retainedResponses.length, `${what}.response.result.fields`); + if (record.m.has("execution")) { + const execution = exact(record.m.get("execution"), `${what}.response.result.execution`, ["resolvedSettings", "retainedBytes"]); + const settings = exact(execution.m.get("resolvedSettings"), `${what}.resolvedSettings`, ["model", "profile", "version", "settings"]); + for (const name of ["model", "profile", "version"]) nonEmpty(settings.m.get(name), `${what}.resolvedSettings.${name}`); + const values = object(settings.m.get("settings"), `${what}.resolvedSettings.settings`); + if (!inCanonDomain(values)) malformed(`${what}.resolvedSettings.settings is outside the canon domain`); + const retained = array(execution.m.get("retainedBytes"), `${what}.retainedBytes`); + if (retained.length > BOUNDS.retainedResponses) malformed(`${what}.retainedBytes has more than 64 entries`); + retained.forEach((x, k) => { + const item = exact(x, `${what}.retainedBytes[${k}]`, ["mediaType", "base64"]); + nonEmpty(item.m.get("mediaType"), `${what}.retainedBytes[${k}].mediaType`); + const encoded = string(item.m.get("base64"), `${what}.retainedBytes[${k}].base64`); + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)) malformed(`${what}.retainedBytes[${k}].base64 is not base64`); + }); + } + readAnswers(record.m.get(recipe.resultMember), recipe, retainedResponses.length, `${what}.response.result.${recipe.resultMember}`); const receipt = r.m.has("receipt") ? object(r.m.get("receipt"), `${what}.response.receipt`) : obj(); const salts = r.m.has("salts") ? readSalts(r.m.get("salts"), `${what}.response.salts`) : obj(); out.set(name, { result: r.m.get("result")!, receipt, salts, own: r.m.has("receipt") }); diff --git a/tools/dialect-verify-ts/src/prepare.ts b/tools/dialect-verify-ts/src/prepare.ts index 7a9aa3d..2182a6e 100644 --- a/tools/dialect-verify-ts/src/prepare.ts +++ b/tools/dialect-verify-ts/src/prepare.ts @@ -2,11 +2,11 @@ import { BOUNDS, scanBytes } from "./bounds.ts"; import { verify as edVerify } from "./ed25519.ts"; import { endpointInScope } from "./endpoint.ts"; -import { DIGEST, Refusal, placeholders, profileDigestOf, sha256hex, stringTemplate } from "./forms.ts"; -import type { Acquisition, ClaimForm, CopyRead, Dialect, ParamType, Profile, Rule, Source } from "./forms.ts"; +import { DIGEST, Refusal, compareDecimals, placeholders, profileDigestOf, sha256hex, stringTemplate } from "./forms.ts"; +import type { Acquisition, ClaimForm, CopyRead, Dialect, ParamType, Profile, QualityFact, Rule, Source } from "./forms.ts"; import { NotRead, OutsideDomain, arr, bool, canon, compareCodePoints, decode, depth, inCanonDomain, isInteger, intValue, jsonEqual, member, - num, obj, parse, resolve, sortedDistinct, str, utf8, writeAt, + num, obj, parse, preparationForm, resolve, sortedDistinct, str, utf8, writeAt, } from "./json.ts"; import type { JArr, JObj, JStr, JVal } from "./json.ts"; import { instant } from "./time.ts"; @@ -55,6 +55,7 @@ export type Outcome = { // it has one (§9.1, §9.3); and the research record itself, for a later reconciliation (§6.4) citations?: JObj[]; record?: JVal; + transformations: Map; }; export type Recomputed = { @@ -75,13 +76,131 @@ function ofType(v: JVal, t: ParamType): boolean { // ---- reads (§6) ---- -function applyCopy(copy: CopyRead, value: JVal, who: string): Claim { +const QUALITY_DECIMAL = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/; +const QUALITY_WHITESPACE_EDGE = /^[\u0009-\u000d\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+|[\u0009-\u000d\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/gu; +const QUALITY_WHITESPACE_RUN = /[\u0009-\u000d\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+/gu; +const qualityTrim = (value: string) => value.replace(QUALITY_WHITESPACE_EDGE, ""); + +function decimalNumber(literal: string): string { + const m = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(literal)!; + const digits = m[2] + (m[3] ?? ""); + const point = m[2].length + Number(m[4] ?? "0"); + const magnitude = point <= 0 ? `0.${"0".repeat(-point)}${digits}` : point >= digits.length ? digits + "0".repeat(point - digits.length) : `${digits.slice(0, point)}.${digits.slice(point)}`; + return m[1] + magnitude; +} + +function converted(v: JVal, rule: JObj): { value?: JVal; failure?: string } { + if (v.t === "null") return { value: v }; + const kind = (rule.m.get("kind") as JStr).v; + if (kind === "decimal-from-number") return v.t === "num" ? { value: str(decimalNumber(v.lit)) } : { failure: "not-a-number" }; + if (v.t !== "str") return { failure: "invalid-encoding" }; + if (kind === "trim") return { value: str(qualityTrim(v.v)) }; + if (kind === "collapse-whitespace") return { value: str(qualityTrim(v.v).replace(QUALITY_WHITESPACE_RUN, " ")) }; + if (kind === "boolean-from-text") { + for (const [name, answer] of [["true", true], ["false", false]] as const) { + if ((rule.m.get(name) as JArr).a.some((x) => (x as JStr).v === v.v)) return { value: bool(answer) }; + } + return { failure: "out-of-domain" }; + } + if (kind === "decimal-from-text") { + const whitespace = (rule.m.get("whitespace") as JStr).v; + let text = whitespace === "trim" ? qualityTrim(v.v) : v.v; + if (whitespace === "reject" && text !== qualityTrim(text)) return { failure: "invalid-encoding" }; + let sign = ""; + if (/^[+-]/.test(text)) { sign = text[0]; text = text.slice(1); } + const signs = (rule.m.get("sign") as JStr).v; + if ((sign === "+" && signs !== "plus-minus") || (sign === "-" && signs === "none")) return { failure: "invalid-encoding" }; + const thousands = (rule.m.get("thousandsSeparator") as JStr).v; + if (thousands) text = text.split(thousands).join(""); + text = text.split((rule.m.get("decimalSeparator") as JStr).v).join("."); + const result = (sign === "-" ? "-" : "") + text; + return QUALITY_DECIMAL.test(result) ? { value: str(result) } : { failure: "not-a-number" }; + } + const format = (rule.m.get("format") as JStr).v; + const match = (format === "YYYY-MM-DD" ? /^(\d{4})-(\d{2})-(\d{2})$/ : /^(\d{2})\/(\d{2})\/(\d{4})$/).exec(v.v); + if (!match) return { failure: "invalid-encoding" }; + const parts = format === "YYYY-MM-DD" ? [match[1], match[2], match[3]] : format === "MM/DD/YYYY" ? [match[3], match[1], match[2]] : [match[3], match[2], match[1]]; + const [year, month, day] = parts.map(Number); + if (!validDate(year, month, day)) return { failure: "invalid-encoding" }; + return { value: str(`${parts[0]}-${parts[1]}-${parts[2]}`) }; +} + +function validDate(year: number, month: number, day: number): boolean { + if (year < 1 || month < 1 || month > 12 || day < 1) return false; + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return day <= days[month - 1]; +} + +function qualityType(v: JVal, type: QualityFact["qualityType"]): boolean { + if (v.t === "null") return true; + if (type === "string") return v.t === "str"; + if (type === "decimal") return v.t === "str" && QUALITY_DECIMAL.test(v.v); + if (type === "boolean") return v.t === "bool"; + if (type === "date" && v.t === "str") { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v.v); + return m !== null && validDate(Number(m[1]), Number(m[2]), Number(m[3])); + } + return false; +} + +function applyCopy(copy: CopyRead, value: JVal, who: string, transformations?: Map): Claim { const facts = obj(); for (const f of copy.facts) { - const v = resolve(value, f.source); - if (v === undefined) continue; - if (!inCanonDomain(v)) finding("read-failed", `${who}: the value at ${f.source} is outside the canon domain`); - writeAt(facts, f.target, v); + if (f.qualityType === undefined) { + const legacy = resolve(value, f.source!); + if (legacy === undefined) continue; + if (!inCanonDomain(legacy)) finding("read-failed", `${who}: the value at ${f.source} is outside the canon domain`); + writeAt(facts, f.target, legacy); + continue; + } + const observations = f.observations ?? [{ id: "source", source: f.source! }]; + const applications: JObj[] = [], present = new Map(); + for (const observation of observations) { + const raw = resolve(value, observation.source); + const fields: [string, JVal][] = [["id", str(observation.id)], ["source", str(observation.source)], ["state", str("missing")]]; + if (raw !== undefined) { + const result = f.convert ? converted(raw, f.convert) : { value: raw }; + fields[2] = ["state", str(result.failure ? "failed" : "present")]; + fields.push(["sourceText", str(raw.t === "num" ? raw.lit : decode(preparationForm(raw)))]); + if (f.convert) fields.push(["rule", f.convert]); + if (result.failure) fields.push(["failure", obj([["code", str(result.failure)]])]); + else { fields.push(["result", result.value!]); present.set(observation.id, result.value!); } + } + applications.push(obj(fields)); + } + const findings: JObj[] = []; + let selected: JVal | undefined, reconciliation: JObj | undefined; + if (f.reconcile) { + const fields: [string, JVal][] = [["mode", str(f.reconcile.mode)], ["observed", arr(Array.from(present.keys()).map(str))]]; + if (f.reconcile.mode === "agree" && present.size) { + selected = Array.from(present.values())[0]; + if (Array.from(present.values()).slice(1).some((x) => !jsonEqual(selected!, x))) { selected = undefined; fields.push(["conflict", bool(true)]); findings.push(obj([["code", str("conflict")]])); } + else fields.push(["agreed", bool(true)]); + } else { + const chosen = (f.reconcile.precedence ?? observations.map((x) => x.id)).find((x) => present.has(x)); + if (chosen !== undefined) { selected = present.get(chosen); fields.push(["chosen", str(chosen)]); } + } + reconciliation = obj(fields); + } else selected = present.values().next().value; + if (selected === undefined && f.required && !findings.length) findings.push(obj([["code", str("missing-required")]])); + if (selected !== undefined && !qualityType(selected, f.qualityType)) { selected = undefined; findings.push(obj([["code", str("wrong-type")]])); } + if (selected !== undefined && selected.t !== "null" && f.domain) { + let inside = f.domain.m.has("in") && (f.domain.m.get("in") as JArr).a.some((x) => jsonEqual(x, selected!)); + if (f.domain.m.has("decimalRange") && selected.t === "str" && QUALITY_DECIMAL.test(selected.v)) { + const range = f.domain.m.get("decimalRange") as JObj; + inside = compareDecimals((range.m.get("min") as JStr).v, selected.v) <= 0 && compareDecimals(selected.v, (range.m.get("max") as JStr).v) <= 0; + } + if (!inside) { selected = undefined; findings.push(obj([["code", str("out-of-domain")]])); } + } + if (transformations && f.qualityType) { + const fields: [string, JVal][] = [["applications", arr(applications)], ["findings", arr(findings)]]; + if (reconciliation) fields.push(["reconciliation", reconciliation]); + transformations.set(f.target, obj(fields)); + } + if (selected === undefined) continue; + if (!inCanonDomain(selected)) finding("read-failed", `${who}: the value for ${f.target} is outside the canon domain`); + writeAt(facts, f.target, selected); } const evidence = new Map(); for (const e of copy.evidence) { @@ -90,10 +209,17 @@ function applyCopy(copy: CopyRead, value: JVal, who: string): Claim { if (v.t !== "str" || !AVAILABILITY.has(v.v)) finding("read-failed", `${who}: the value at ${e.source} is not an availability`); evidence.set(e.requirement, (v as JStr).v); } - const basis = sortedDistinct([...copy.facts.map((f) => f.source), ...copy.evidence.map((e) => e.source)]); + const basis = sortedDistinct([...copy.facts.flatMap((f) => f.observations?.map((o) => o.source) ?? [f.source!]), ...copy.evidence.map((e) => e.source)]); return { facts, evidence, status: "resolved", reason: "copied", basis }; } +/** Apply one shared hand-written §6.2a vector through the preparation implementation. */ +export function applyQualityFact(copy: CopyRead, value: JVal): { facts: JObj; transformations: Map } { + const transformations = new Map(); + const claim = applyCopy(copy, value, "quality vector", transformations); + return { facts: claim.facts, transformations }; +} + function applyRule(rule: Rule, value: JVal, scope: Scope, who: string): Claim { const basis = new Set(); const param = (n: string) => scope.get(n)!.value; @@ -402,7 +528,7 @@ function researchClaim(s: Source, outcome: Outcome, outcomes: Outcome[]): Claim value = obj([["current", outcome.record!], ["sources", obj(held)]]); if (depth(value) > BOUNDS.readNesting) throw new Refusal("input-over-bound", `${s.name}: the combined value nests too deep`); } - return s.read.copy ? applyCopy(s.read.copy, value, s.name) : applyRule(s.read.rule!, value, outcome.scope, s.name); + return s.read.copy ? applyCopy(s.read.copy, value, s.name, outcome.transformations) : applyRule(s.read.rule!, value, outcome.scope, s.name); } /** §5.2, the times: the instants check 1 found present (`checkReceipt`), so `acquisition-stale` names only one @@ -515,11 +641,12 @@ export function recompute(d: Dialect, caseDoc: JVal, acquisitions: Map(); outcomes.push({ name: "case", scope: new Map([...caseScope, ["runAt", runAt]]), skipped: false, - claim: applyCopy(d.caseCopy, caseDoc, "case"), + claim: applyCopy(d.caseCopy, caseDoc, "case", caseTransformations), class: "asserted", generatedInfluence: false, calculatorComputed: false, @@ -530,6 +657,7 @@ export function recompute(d: Dialect, caseDoc: JVal, acquisitions: Map s.name === name)) finding("acquisition-unexpected", `the acquisition ${name} names no source`); @@ -663,6 +792,7 @@ export function recompute(d: Dialect, caseDoc: JVal, acquisitions: Map 0) { const parameters = [...o.source.parameters].sort((a, b) => compareCodePoints(a.name, b.name)); @@ -726,7 +856,7 @@ function claimValue(c: Claim): JObj { function copyCandidates(copy: CopyRead): Map { const m = new Map(); - for (const f of copy.facts) m.set(`fact:${f.target}`, [f.source]); + for (const f of copy.facts) m.set(`fact:${f.target}`, f.observations?.map((o) => o.source) ?? [f.source!]); for (const e of copy.evidence) m.set(`evidence:${e.requirement}`, [e.source]); return m; } diff --git a/tools/dialect-verify-ts/test/units.test.ts b/tools/dialect-verify-ts/test/units.test.ts index abf5619..368f6f2 100644 --- a/tools/dialect-verify-ts/test/units.test.ts +++ b/tools/dialect-verify-ts/test/units.test.ts @@ -11,8 +11,9 @@ import { test } from "node:test"; import { fileURLToPath } from "node:url"; import { scanBytes } from "../src/bounds.ts"; import { add, decodePoint, encodePoint, keyAcceptable, smallOrder, verify as edVerify } from "../src/ed25519.ts"; -import { bindDialect, readBinding, readDialect } from "../src/forms.ts"; -import { NotRead, arr, canon, isIntegerLiteral, member, num, obj, parse, preparationForm, read, resolve, str, utf8 } from "../src/json.ts"; +import { Refusal, bindDialect, countsWithin, readAcquisitions, readBinding, readDialect, readQualityFact } from "../src/forms.ts"; +import { NotRead, arr, canon, isIntegerLiteral, jsonEqual, member, num, obj, parse, preparationForm, read, resolve, str, utf8 } from "../src/json.ts"; +import { applyQualityFact } from "../src/prepare.ts"; import type { JObj, JVal } from "../src/json.ts"; import { instant } from "../src/time.ts"; import { loadVector } from "../src/vector.ts"; @@ -22,6 +23,134 @@ import type { Inputs } from "../src/verify.ts"; const VECTORS = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "conformance", "dialect", "vectors"); const text = (b: Uint8Array) => Buffer.from(b).toString("utf8"); +test("§6.2a: both readers apply every hand-written quality vector", () => { + const path = join(VECTORS, "..", "quality-vectors.json"); + const root = parse(readFileSync(path, "utf8")) as JObj; + for (const rowValue of (member(root, "cases") as { a: JVal[] }).a) { + const row = rowValue as JObj; + const id = (member(row, "id") as { v: string }).v; + try { + const boundedCopy = obj([["facts", arr([member(row, "entry")!])]]); + const boundedRead = obj([["copy", boundedCopy]]); + const boundedSource = obj([["read", boundedRead]]); + const boundedDialect = obj([["case", obj()], ["sources", arr([boundedSource])]]); + if (!countsWithin(boundedDialect, obj())) throw new Refusal("input-over-bound", `${id} exceeds a fixed bound`); + const copy = readQualityFact(member(row, "entry")!, id); + const result = applyQualityFact(copy, member(row, "input")!); + assert.equal(member(row, "expectedRefusal"), undefined, id); + const expected = member(row, "expected"); + const actual = resolve(result.facts, "/value"); + if (expected !== undefined) assert.ok(actual !== undefined && jsonEqual(actual, expected), id); + else assert.equal(actual, undefined, id); + const transformation = result.transformations.get("/value")!; + const expectedFinding = member(row, "expectedFinding"); + if (expectedFinding !== undefined) { + const findings = member(transformation, "findings") as { a: JObj[] }; + assert.ok(findings.a.some((x) => jsonEqual(member(x, "code")!, expectedFinding)), id); + } + const expectedFailure = member(row, "expectedFailure"); + if (expectedFailure !== undefined) { + const application = (member(transformation, "applications") as { a: JObj[] }).a[0]; + assert.ok(jsonEqual(member(member(application, "failure"), "code")!, expectedFailure), id); + } + const expectedChosen = member(row, "expectedChosen"); + if (expectedChosen !== undefined) assert.ok(jsonEqual(member(member(transformation, "reconciliation"), "chosen")!, expectedChosen), id); + } catch (error) { + if (!(error instanceof Refusal)) throw error; + const expected = member(row, "expectedRefusal"); + assert.equal(expected?.t === "str" ? expected.v : undefined, error.code, id); + } + } +}); + +test("§4.3: both readers check the typed result and retained replay vectors", () => { + const path = join(VECTORS, "..", "research-result-vectors.json"); + const root = parse(readFileSync(path, "utf8")) as JObj; + const dialect = readDialect(member(root, "dialect")!); + const recipe = dialect.sources[0].research!; + assert.equal(recipe.resultMember, "results"); + for (const rowValue of (member(root, "cases") as { a: JVal[] }).a) { + const row = rowValue as JObj; + const id = (member(row, "id") as { v: string }).v; + const acquisition = obj([["research", obj([["response", obj([["result", member(row, "record")!]])]])]]); + try { + readAcquisitions(acquisition, new Map([["research", recipe]])); + assert.equal((member(row, "expected") as { v: string }).v, "accepted", id); + const applied = applyQualityFact(dialect.sources[0].read.copy!, member(row, "record")!); + assert.equal((resolve(applied.facts, "/research/state") as { v: string }).v, "missing", id); + assert.equal((member(member((member(applied.transformations.get("/research/state"), "applications") as { a: JObj[] }).a[0], "rule"), "kind") as { v: string }).v, "trim", id); + } catch (error) { + if (!(error instanceof Refusal)) throw error; + assert.equal((member(row, "expected") as { v: string }).v, error.code, id); + } + } +}); + +test("§4.3: typed result refusal mutations are caught", () => { + const path = join(VECTORS, "..", "research-result-vectors.json"); + const source = JSON.parse(readFileSync(path, "utf8")); + for (const [name, change] of [ + ["empty contract", (d: any) => { d.dialect.sources[0].results = []; }], + ["missing cardinality", (d: any) => { delete d.dialect.sources[0].results[0].cardinality; }], + ] as const) { + const changed = structuredClone(source); + change(changed); + assert.throws(() => readDialect(parse(JSON.stringify(changed.dialect))), (e: unknown) => e instanceof Refusal && e.code === "input-malformed", name); + } + + const tooMany = structuredClone(source); + const seed = tooMany.dialect.sources[0].results[0]; + tooMany.dialect.sources[0].results = Array.from({ length: 65 }, (_, n) => ({ ...seed, id: `field-${n}` })); + assert.equal(countsWithin(parse(JSON.stringify(tooMany.dialect)), obj()), false); + + const missing = structuredClone(source.cases[0].record); + delete missing.execution.retainedBytes; + const dialect = readDialect(parse(JSON.stringify(source.dialect))); + const acquisition = (record: unknown) => obj([["research", obj([["response", obj([["result", parse(JSON.stringify(record))]])]])]]); + assert.throws(() => readAcquisitions(acquisition(missing), new Map([["research", dialect.sources[0].research!]])), + (e: unknown) => e instanceof Refusal && e.code === "input-malformed"); + + for (const [name, change] of [ + ["missing execution", (record: any) => { delete record.execution; }], + ["invalid retained bytes", (record: any) => { record.execution.retainedBytes[0].base64 = "!"; }], + ] as const) { + const changed = structuredClone(source.cases[0].record); + change(changed); + assert.throws(() => readAcquisitions(acquisition(changed), new Map([["research", dialect.sources[0].research!]])), + (e: unknown) => e instanceof Refusal && e.code === "input-malformed", name); + } + + const retained = structuredClone(source.cases[0].record); + retained.execution.retainedBytes = Array.from({ length: 65 }, () => ({ mediaType: "application/json", base64: "e30=" })); + assert.equal(countsWithin(parse(JSON.stringify(source.dialect)), acquisition(retained)), false); +}); + +test("§2: semantic declarations change the dialect digest", () => { + const path = join(VECTORS, "..", "research-result-vectors.json"); + const source = JSON.parse(readFileSync(path, "utf8")).dialect; + const digest = (document: unknown) => createHash("sha256").update(canon(parse(JSON.stringify(document)))).digest("hex"); + for (const change of [ + (d: any) => { d.sources[0].results[0].description = "A different contract."; }, + (d: any) => { d.sources[0].question = "Use a different request."; }, + (d: any) => { d.sources[0].acquisitionSources[0].version = "2"; }, + ]) { + const changed = structuredClone(source); + change(changed); + readDialect(parse(JSON.stringify(changed))); + assert.notEqual(digest(source), digest(changed)); + } + const conversion = structuredClone(source); + conversion.sources[0].read.copy.facts[0] = { + target: "/research/state", source: "/results/summary/status", + type: "string", required: true, convert: { kind: "trim" }, + }; + const changed = structuredClone(conversion); + changed.sources[0].read.copy.facts[0].convert = { kind: "collapse-whitespace" }; + readDialect(parse(JSON.stringify(conversion))); + readDialect(parse(JSON.stringify(changed))); + assert.notEqual(digest(conversion), digest(changed)); +}); + test("§9.2 and §9.3: a skipped source keeps its claim and every declared dependency", () => { const dir = join(VECTORS, "skipped-source-dependency-unavailable"); const inputs = loadVector(dir, true); diff --git a/tools/dialect_verify.py b/tools/dialect_verify.py index 50bd914..100c400 100644 --- a/tools/dialect_verify.py +++ b/tools/dialect_verify.py @@ -43,6 +43,9 @@ import json import re import sys +import datetime +import base64 +import binascii from dataclasses import dataclass, field from decimal import Decimal from pathlib import Path @@ -273,7 +276,8 @@ def read_document(data: bytes, what: str, max_depth: int = MAX_DOCUMENT_DEPTH) - "web": ({"url"}, set()), } # §4.3: a `research` source's members beside §4's, and its binding members (§4.4), all three or none. -RESEARCH_MEMBERS = {"maxAge", "question", "constraints", "model", "evidence", "fields", "capabilities"} +RESEARCH_MEMBERS = {"maxAge", "question", "constraints", "model", "evidence", "capabilities"} +RESEARCH_OPTIONAL = {"fields", "results", "acquisitionSources"} RESEARCH_BINDING = ("profile", "profileDigest", "webProfiles") RESEARCH_TYPES = ("integer", "decimal", "boolean", "text", "text-list", "range") CAPABILITIES = ("answer", "search", "page") @@ -714,6 +718,8 @@ class Read: candidates: dict[tuple[str, str], list[str]] = field(default_factory=dict) # a reconciliation read's earlier research sources, in their order (§6.4); None for any other read sources: list[str] | None = None + # fact target -> its conversion, reconciliation and quality declaration (§6.2a) + quality: dict[str, dict[str, Any]] = field(default_factory=dict) @dataclass @@ -727,6 +733,7 @@ class Research: model: str request: dict[str, Any] # the source's request but its `question`, which is instantiated web: dict[str, tuple[str, str]] = field(default_factory=dict) # capability -> (profile, digest) + result_member: str = "fields" @dataclass @@ -902,6 +909,109 @@ def fact_target(pointer: Any, what: str) -> str: return pointer +QUALITY_TYPES = {"string", "decimal", "boolean", "date"} +CONVERSION_KINDS = { + "decimal-from-text", "decimal-from-number", "boolean-from-text", "trim", + "collapse-whitespace", "date-from-text", +} +DECIMAL_TEXT = re.compile(r"-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?") +QUALITY_WHITESPACE = "\u0009\u000a\u000b\u000c\u000d\u0020\u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000" +QUALITY_WHITESPACE_RUN = re.compile(f"[{QUALITY_WHITESPACE}]+") + + +def check_conversion(value: Any, what: str) -> dict[str, Any]: + """§6.2a: the six closed deterministic conversion declarations.""" + if not isinstance(value, dict): + raise malformed(f"{what}: convert is not an object") + kind = value.get("kind") + if kind not in CONVERSION_KINDS: + raise malformed(f"{what}: unknown conversion kind") + if kind == "decimal-from-text": + closed(value, {"kind", "decimalSeparator", "thousandsSeparator", "sign", "whitespace"}, set(), what) + if value["decimalSeparator"] not in (".", ","): + raise malformed(f"{what}: decimalSeparator") + if not isinstance(value["thousandsSeparator"], str) or value["thousandsSeparator"] == value["decimalSeparator"]: + raise malformed(f"{what}: thousandsSeparator") + if value["sign"] not in ("none", "minus", "plus-minus") or value["whitespace"] not in ("reject", "trim"): + raise malformed(f"{what}: sign or whitespace") + elif kind == "boolean-from-text": + closed(value, {"kind", "true", "false"}, set(), what) + for member in ("true", "false"): + spellings = value[member] + if not isinstance(spellings, list) or not spellings or len(spellings) > 16 or not all(isinstance(x, str) for x in spellings): + raise malformed(f"{what}: {member} spellings") + if len(set(spellings)) != len(spellings): + raise malformed(f"{what}: repeated {member} spelling") + if set(value["true"]) & set(value["false"]): + raise malformed(f"{what}: a spelling is both true and false") + elif kind == "date-from-text": + closed(value, {"kind", "format"}, set(), what) + if value["format"] not in ("YYYY-MM-DD", "MM/DD/YYYY", "DD/MM/YYYY"): + raise malformed(f"{what}: unknown date format") + else: + closed(value, {"kind"}, set(), what) + return value + + +def check_quality_fact(entry: dict[str, Any], what: str) -> dict[str, Any] | None: + """Read and close the optional per-target conversion/reconciliation/quality contract.""" + new = set(entry) - {"target", "source"} + if not new: + return None + allowed = {"target", "source", "observations", "reconcile", "convert", "type", "required", "domain"} + if set(entry) - allowed: + raise malformed(f"{what}: unknown quality member") + if ("source" in entry) == ("observations" in entry): + raise malformed(f"{what}: exactly one of source and observations is required") + if "source" in entry and not is_pointer(entry["source"]): + raise malformed(f"{what}: source is not a pointer") + observations = entry.get("observations") + if observations is not None: + if not isinstance(observations, list) or len(observations) < 2: + raise malformed(f"{what}: observations are not a list of two to 16") + ids: list[str] = [] + for index, observation in enumerate(observations): + closed(observation, {"id", "source"}, set(), f"{what} observation {index}") + if not isinstance(observation["id"], str) or not observation["id"] or not is_pointer(observation["source"]): + raise malformed(f"{what}: observation id or source") + ids.append(observation["id"]) + if len(set(ids)) != len(ids): + raise malformed(f"{what}: repeated observation id") + reconcile = closed(entry.get("reconcile"), {"mode"}, {"precedence"}, f"{what} reconcile") + if reconcile["mode"] not in ("precedence", "agree", "first-present"): + raise malformed(f"{what}: reconciliation mode") + if reconcile["mode"] == "precedence": + precedence = reconcile.get("precedence") + if (not isinstance(precedence, list) or len(precedence) != len(ids) + or not all(isinstance(item, str) for item in precedence) + or set(precedence) != set(ids)): + raise malformed(f"{what}: precedence is not exactly the observation ids") + elif "precedence" in reconcile: + raise malformed(f"{what}: precedence is present for another mode") + elif "reconcile" in entry: + raise malformed(f"{what}: reconcile without observations") + if "convert" in entry: + check_conversion(entry["convert"], f"{what} convert") + if entry.get("type") not in QUALITY_TYPES: + raise malformed(f"{what}: quality type") + if not isinstance(entry.get("required"), bool): + raise malformed(f"{what}: required is not boolean") + if "domain" in entry: + domain = entry["domain"] + if not isinstance(domain, dict) or (set(domain) != {"in"} and set(domain) != {"decimalRange"}): + raise malformed(f"{what}: domain form") + if "in" in domain: + if not isinstance(domain["in"], list) or not domain["in"] or len(domain["in"]) > 64 or not all(in_canon_domain(x) for x in domain["in"]): + raise malformed(f"{what}: domain in list") + else: + bounds = closed(domain["decimalRange"], {"min", "max"}, set(), f"{what} decimal range") + if not all(isinstance(bounds[k], str) and DECIMAL_TEXT.fullmatch(bounds[k]) for k in ("min", "max")): + raise malformed(f"{what}: decimal range endpoint") + if Decimal(bounds["min"]) > Decimal(bounds["max"]): + raise malformed(f"{what}: reversed decimal range") + return entry + + def check_read(value: Any, scope: dict[str, str], what: str) -> Read: read = closed(value, set(), {"unwrap", "copy", "rule"}, what) if ("copy" in read) == ("rule" in read): @@ -923,12 +1033,17 @@ def check_read(value: Any, scope: dict[str, str], what: str) -> Read: if not isinstance(facts, list) or not isinstance(evidence, list): raise malformed(f"{what}: copy facts and evidence are arrays") for entry in facts: - closed(entry, {"target", "source"}, set(), what + " copy fact") + if not isinstance(entry, dict): + raise malformed(f"{what}: copy fact is not an object") + quality = check_quality_fact(entry, what + " copy fact") + if quality is None: + closed(entry, {"target", "source"}, set(), what + " copy fact") target = fact_target(entry["target"], what) - if not is_pointer(entry["source"]): - raise malformed(f"{what}: a copy source that is not a pointer") + sources = [entry["source"]] if "source" in entry else [x["source"] for x in entry["observations"]] result.fact_targets.append(target) - result.candidates.setdefault(("fact", target), []).append(entry["source"]) + result.candidates.setdefault(("fact", target), []).extend(sources) + if quality is not None: + result.quality[target] = quality seen_requirements: set[str] = set() for entry in evidence: closed(entry, {"requirement", "source"}, set(), what + " copy evidence") @@ -1193,7 +1308,9 @@ def parse_dialect(document: Any) -> Dialect: elif kind == "research": # §4.3, §4.4: published unbound, with none of the three binding members, or bound, with all # three; a source with only some of them, or an operation's catalog member, is malformed. - closed(raw, {"name", "kind", "read"} | RESEARCH_MEMBERS, {"parameters", *RESEARCH_BINDING}, what) + closed(raw, {"name", "kind", "read"} | RESEARCH_MEMBERS, {"parameters", *RESEARCH_BINDING, *RESEARCH_OPTIONAL}, what) + if ("fields" in raw) == ("results" in raw): + raise malformed(f"{what}: exactly one of fields and results is required") given = [member for member in RESEARCH_BINDING if member in raw] if given and len(given) != len(RESEARCH_BINDING): raise malformed(f"{what}: some but not all of profile, profileDigest and webProfiles") @@ -1428,30 +1545,58 @@ def check_research(raw: dict[str, Any], scope: dict[str, str], what: str) -> Res evidence = raw["evidence"] if not isinstance(evidence, list) or not all(isinstance(item, str) and item for item in evidence): raise malformed(f"{what}: evidence is not an array of non-empty strings") - fields = raw["fields"] + result_member = "results" if "results" in raw else "fields" + fields = raw[result_member] if not isinstance(fields, list) or not fields: raise malformed(f"{what}: fields is not an array of one declaration at least") types: dict[str, str] = {} for index, declaration in enumerate(fields): where = f"{what} field {index}" - closed(declaration, {"name", "type", "required", "description"}, set(), where) - name = declaration["name"] + if result_member == "results": + closed(declaration, {"id", "type", "required", "description", "cardinality"}, set(), where) + name = declaration["id"] + if declaration["type"] not in ("string", "decimal", "boolean", "string-list"): + raise malformed(f"{where}: a result type outside the closed set") + else: + closed(declaration, {"name", "type", "required", "description"}, {"cardinality"}, where) + name = declaration["name"] if not isinstance(name, str) or not name or name == "case": raise malformed(f"{where}: a name that is not a non-empty string other than case") if name in types: raise malformed(f"{where}: a field name given twice") - if not isinstance(declaration["type"], str) or declaration["type"] not in RESEARCH_TYPES: + mapped_type = {"string": "text", "string-list": "text-list"}.get(declaration["type"], declaration["type"]) + if result_member == "results" and declaration["cardinality"] == "many": + mapped_type += "[]" + if not isinstance(mapped_type, str) or (mapped_type not in RESEARCH_TYPES and not (mapped_type.endswith("[]") and mapped_type[:-2] in RESEARCH_TYPES)): raise malformed(f"{where}: a type other than the six") if not isinstance(declaration["required"], bool): raise malformed(f"{where}: required is not a boolean") if not isinstance(declaration["description"], str) or not declaration["description"]: raise malformed(f"{where}: description is not a non-empty string") - types[name] = declaration["type"] + if declaration.get("cardinality", "many" if declaration["type"] == "text-list" else "one") not in ("one", "many"): + raise malformed(f"{where}: cardinality is not one or many") + types[name] = mapped_type capabilities = check_capabilities(raw["capabilities"], what) if capabilities[0] != "answer": raise malformed(f"{what}: capabilities without answer, which is required") - request = {name: raw[name] for name in ("constraints", "model", "evidence", "fields", "capabilities")} - research = Research(question, fields, types, capabilities, model, request) + if "acquisitionSources" in raw: + declared_sources = raw["acquisitionSources"] + if not isinstance(declared_sources, list) or not declared_sources or len(declared_sources) > MAX_SOURCES: + raise malformed(f"{what}: acquisitionSources is not one to 16 entries") + for index, declared_source in enumerate(declared_sources): + closed(declared_source, {"profile", "version", "purpose", "scope", "freshness"}, set(), f"{what} acquisition source {index}") + if not all(isinstance(declared_source[k], str) and declared_source[k] for k in ("profile", "version", "purpose")): + raise malformed(f"{what}: acquisition source names") + if not isinstance(declared_source["scope"], dict) or not in_canon_domain(declared_source["scope"]): + raise malformed(f"{what}: acquisition source scope") + if not is_integer(declared_source["freshness"]) or not 0 <= integer_of(declared_source["freshness"]) <= 31536000: + raise malformed(f"{what}: acquisition source freshness") + request_names = ["constraints", "model", "evidence", result_member, "capabilities"] + if "acquisitionSources" in raw: + request_names.append("acquisitionSources") + request = {name: raw[name] for name in request_names} + normalized_fields = [({**declaration, "name": declaration["id"]} if result_member == "results" else declaration) for declaration in fields] + research = Research(question, normalized_fields, types, capabilities, model, request, result_member=result_member) if "webProfiles" in raw: research.web = check_web_profiles(raw["webProfiles"], capabilities, what) return research @@ -1487,13 +1632,14 @@ def check_research_read( return check_reconciliation(value, scope, what, source, earlier) read = check_read(value, scope, what) declared = source.research.types + result_member = source.research.result_member def readable(pointer: str, taken: bool) -> None: tokens = pointer_tokens(pointer) assert tokens is not None - if tokens == ["fields"]: + if tokens == [result_member]: return - if len(tokens) >= 2 and tokens[0] == "fields" and tokens[1] in declared: + if len(tokens) >= 2 and tokens[0] == result_member and tokens[1] in declared: rest = tokens[2:] if not rest or rest[0] == "value" or rest in (["status"], ["cites"]): return @@ -1507,7 +1653,10 @@ def readable(pointer: str, taken: bool) -> None: for pointer in read.unwrap: readable(pointer, True) if read.copy is not None: - for entry in read.copy["facts"] + read.copy["evidence"]: + for entry in read.copy["facts"]: + for pointer in ([entry["source"]] if "source" in entry else [item["source"] for item in entry["observations"]]): + readable(pointer, True) + for entry in read.copy["evidence"]: readable(entry["source"], True) else: assert read.rule is not None @@ -1702,6 +1851,12 @@ def check_salts(value: Any, what: str) -> None: def check_typed(value: Any, kind: str, what: str) -> None: """§4.3: a value of a field's declared type, in its exact encoding; `null` is never one. The maxima of §12 are counted before any form (`count_bounds`).""" + if kind.endswith("[]"): + if not isinstance(value, list) or len(value) > MAX_LIST_ITEMS: + raise malformed(f"{what}: a value that is not of the declared type {kind}") + for index, item in enumerate(value): + check_typed(item, kind[:-2], f"{what} item {index}") + return if kind == "integer": ok = is_integer(value) elif kind == "decimal": @@ -1756,7 +1911,7 @@ def check_answers(answers: Any, research: Research, responses: int, what: str) - where = f"{what} field {name}" kind = research.types[name] status = entry.get("status") if isinstance(entry, dict) else None - if status == "found": + if status in ("found", "present"): closed(entry, {"status", "cites", "value"}, set(), where) check_cites(entry["cites"], responses, 1, where) check_typed(entry["value"], kind, where) @@ -1774,9 +1929,13 @@ def check_answers(answers: Any, research: Research, responses: int, what: str) - first = observations[0]["value"] if all(json_equal(first, observation["value"]) for observation in observations): raise malformed(f"{where}: no two conflicting values differ") - elif isinstance(status, str) and status in ("not-found", "stale"): - closed(entry, {"status", "cites"}, set(), where) + elif isinstance(status, str) and status in ("not-found", "missing", "inaccessible", "stale"): + needs_reason = research.result_member == "results" and status in ("missing", "inaccessible", "stale") + required = {"status", "cites", "reason"} if needs_reason else {"status", "cites"} + closed(entry, required, set(), where) check_cites(entry["cites"], responses, 0, where) + if "reason" in entry and (not isinstance(entry["reason"], str) or not entry["reason"]): + raise malformed(f"{where}: reason is not a non-empty string") else: raise malformed(f"{where}: an entry that is not an object of a known status") @@ -1794,7 +1953,10 @@ def check_acquisition_form(name: str, member: Any, research: Research | None) -> response = closed(member["response"], {"result"}, {"receipt", "salts"}, what) if ("receipt" in response) != ("salts" in response): raise malformed(f"{what}: a research record's receipt and salts are both present or both absent") - record = closed(response["result"], {"fields", "responses"}, set(), f"{what}: the research record") + result_member = research.result_member + required_record = {result_member, "responses", "execution"} if result_member == "results" else {result_member, "responses"} + optional_record = set() if result_member == "results" else {"execution"} + record = closed(response["result"], required_record, optional_record, f"{what}: the research record") if not isinstance(record["responses"], list): raise malformed(f"{what}: the research record's responses are not an array") for index, entry in enumerate(record["responses"]): @@ -1808,7 +1970,24 @@ def check_acquisition_form(name: str, member: Any, research: Research | None) -> if not isinstance(retained["receipt"], dict): raise malformed(f"{where}: the receipt is not an object") check_salts(retained["salts"], where) - check_answers(record["fields"], research, len(record["responses"]), f"{what}: the research record") + if "execution" in record: + execution = closed(record["execution"], {"resolvedSettings", "retainedBytes"}, set(), f"{what}: execution") + settings = closed(execution["resolvedSettings"], {"model", "profile", "version", "settings"}, set(), f"{what}: resolved settings") + if (not all(isinstance(settings[k], str) and settings[k] for k in ("model", "profile", "version")) + or not isinstance(settings["settings"], dict) or not in_canon_domain(settings["settings"])): + raise malformed(f"{what}: resolved settings") + retained_bytes = execution["retainedBytes"] + if not isinstance(retained_bytes, list) or len(retained_bytes) > MAX_RETAINED_RESPONSES: + raise malformed(f"{what}: retainedBytes") + for index, item in enumerate(retained_bytes): + closed(item, {"mediaType", "base64"}, set(), f"{what}: retained bytes {index}") + if not isinstance(item["mediaType"], str) or not item["mediaType"] or not isinstance(item["base64"], str): + raise malformed(f"{what}: retained bytes {index}") + try: + base64.b64decode(item["base64"], validate=True) + except (ValueError, binascii.Error): + raise malformed(f"{what}: retained bytes {index} is not base64") + check_answers(record[result_member], research, len(record["responses"]), f"{what}: the research record") if "receipt" in response: if not isinstance(response["receipt"], dict): raise malformed(f"{what}: the receipt is not an object") @@ -1818,6 +1997,10 @@ def check_acquisition_form(name: str, member: Any, research: Research | None) -> def over_by_type(value: Any, kind: Any) -> bool: """§11's third phase: a value measured by its field's declared type, when that type is one of §4.3's six, against the maxima of §12; a value of another form is measured by nothing here.""" + if isinstance(kind, str) and kind.endswith("[]"): + return isinstance(value, list) and ( + len(value) > MAX_LIST_ITEMS or any(over_by_type(item, kind[:-2]) for item in value) + ) if kind == "text": return isinstance(value, str) and utf8_length(value) > MAX_TEXT_BYTES if kind == "text-list": @@ -1859,20 +2042,30 @@ def cites_over(value: Any) -> bool: if not isinstance(source, dict) or source.get("kind") != "research": continue types: dict[str, Any] = {} - fields = source.get("fields") + fields = source.get("results", source.get("fields")) if isinstance(fields, list): if len(fields) > MAX_FIELDS: raise over("more than 64 fields declared") for declaration in fields: if not isinstance(declaration, dict): continue - name = declaration.get("name") + name = declaration.get("id", declaration.get("name")) if too_long(name, MAX_FIELD_NAME_BYTES): raise over("a field name of more than 64 bytes") if too_long(declaration.get("description"), MAX_DESCRIPTION_BYTES): raise over("a field description of more than 2048 bytes") - if isinstance(name, str) and name not in types and declaration.get("type") in RESEARCH_TYPES: - types[name] = declaration["type"] + declared_type = declaration.get("type") + if "results" in source: + declared_type = {"string": "text", "string-list": "text-list"}.get(declared_type, declared_type) + if declaration.get("cardinality") == "many" and isinstance(declared_type, str): + declared_type += "[]" + if (isinstance(name, str) and name not in types and isinstance(declared_type, str) + and (declared_type in RESEARCH_TYPES + or declared_type.endswith("[]") and declared_type[:-2] in RESEARCH_TYPES)): + types[name] = declared_type + acquisition_sources = source.get("acquisitionSources") + if isinstance(acquisition_sources, list) and len(acquisition_sources) > MAX_SOURCES: + raise over("more than 16 acquisition sources") if too_long(source.get("model"), MAX_MODEL_BYTES): raise over("a model name of more than 256 bytes") evidence = source.get("evidence") @@ -1905,7 +2098,11 @@ def cites_over(value: Any) -> bool: for name, member in acquisitions.items() if isinstance(acquisitions, dict) else []: response = member.get("response") if isinstance(member, dict) and name in declared else None record = response.get("result") if isinstance(response, dict) else None - answers = record.get("fields") if isinstance(record, dict) else None + answers = record.get("results", record.get("fields")) if isinstance(record, dict) else None + execution = record.get("execution") if isinstance(record, dict) else None + retained_bytes = execution.get("retainedBytes") if isinstance(execution, dict) else None + if isinstance(retained_bytes, list) and len(retained_bytes) > MAX_RETAINED_RESPONSES: + raise over("more than 64 retained byte entries") if not isinstance(answers, dict): continue if len(answers) > MAX_FIELDS: @@ -1969,6 +2166,9 @@ def copy_targets(copy: Any) -> None: for entry in entries(copy.get("facts")): if isinstance(entry, dict) and isinstance(entry.get("target"), str): facts.add(entry["target"]) + observations = entry.get("observations") if isinstance(entry, dict) else None + if isinstance(observations, list) and len(observations) > MAX_SOURCES: + raise over("more than 16 observations for one target") for entry in entries(copy.get("evidence")): if isinstance(entry, dict) and isinstance(entry.get("requirement"), str): evidence.add(entry["requirement"]) @@ -2229,13 +2429,152 @@ def apply_unwrap(value: Any, unwrap: list[str]) -> Any: return value -def copy_claim(copy: dict[str, Any], value: Any) -> dict[str, Any]: +def _source_text(value: Any) -> str: + if isinstance(value, Num): + return value.literal + return preparation_bytes(value).decode("utf-8") + + +def _convert(value: Any, rule: dict[str, Any]) -> tuple[Any, str | None]: + """Apply one §6.2a conversion; return (ABSENT, code) on a recorded failure.""" + if value is None: + return None, None + kind = rule["kind"] + if kind == "decimal-from-number": + if not isinstance(value, Num): + return ABSENT, "not-a-number" + return format(value.value, "f"), None + if not isinstance(value, str): + return ABSENT, "invalid-encoding" + if kind == "trim": + return value.strip(QUALITY_WHITESPACE), None + if kind == "collapse-whitespace": + return QUALITY_WHITESPACE_RUN.sub(" ", value.strip(QUALITY_WHITESPACE)), None + if kind == "boolean-from-text": + if value in rule["true"]: + return True, None + if value in rule["false"]: + return False, None + return ABSENT, "out-of-domain" + if kind == "decimal-from-text": + text = value.strip(QUALITY_WHITESPACE) if rule["whitespace"] == "trim" else value + if rule["whitespace"] == "reject" and text != text.strip(QUALITY_WHITESPACE): + return ABSENT, "invalid-encoding" + sign = "" + if text[:1] in ("+", "-"): + sign, text = text[0], text[1:] + if (sign == "+" and rule["sign"] != "plus-minus") or (sign == "-" and rule["sign"] == "none"): + return ABSENT, "invalid-encoding" + thousands = rule["thousandsSeparator"] + if thousands: + text = text.replace(thousands, "") + text = text.replace(rule["decimalSeparator"], ".") + rendered = ("-" if sign == "-" else "") + text + if DECIMAL_TEXT.fullmatch(rendered) is None: + return ABSENT, "not-a-number" + return rendered, None + if kind == "date-from-text": + forms = {"YYYY-MM-DD": "%Y-%m-%d", "MM/DD/YYYY": "%m/%d/%Y", "DD/MM/YYYY": "%d/%m/%Y"} + try: + parsed = datetime.datetime.strptime(value, forms[rule["format"]]).date() + except ValueError: + return ABSENT, "invalid-encoding" + return parsed.isoformat(), None + raise AssertionError(kind) + + +def _quality_type(value: Any, kind: str) -> bool: + if value is None: + return True + if kind == "string": + return isinstance(value, str) + if kind == "decimal": + return isinstance(value, str) and DECIMAL_TEXT.fullmatch(value) is not None + if kind == "boolean": + return isinstance(value, bool) + if kind == "date": + try: + return isinstance(value, str) and datetime.date.fromisoformat(value).isoformat() == value + except ValueError: + return False + return False + + +def copy_claim(copy: dict[str, Any], value: Any, transformations: dict[str, Any] | None = None) -> dict[str, Any]: facts: dict[str, Any] = {} availability: dict[str, Any] = {} basis: set[str] = set() for entry in copy["facts"]: - basis.add(entry["source"]) - found = resolve(value, entry["source"]) + if set(entry) == {"target", "source"}: + basis.add(entry["source"]) + found = resolve(value, entry["source"]) + if found is ABSENT: + continue + if not in_canon_domain(found): + raise Finding("read-failed", "a copied value outside the canon domain") + write_at(facts, entry["target"], found) + continue + observations = entry["observations"] if "observations" in entry else [{"id": "source", "source": entry["source"]}] + basis.update(o["source"] for o in observations) + applications = [] + present: list[tuple[str, Any]] = [] + for observation in observations: + raw = resolve(value, observation["source"]) + application: dict[str, Any] = {"id": observation["id"], "source": observation["source"], "state": "missing"} + if raw is not ABSENT: + converted, failure = _convert(raw, entry["convert"]) if "convert" in entry else (raw, None) + application.update({"state": "failed" if failure else "present", "sourceText": _source_text(raw)}) + if "convert" in entry: + application["rule"] = entry["convert"] + if failure: + application["failure"] = {"code": failure} + else: + application["result"] = converted + present.append((observation["id"], converted)) + applications.append(application) + findings: list[dict[str, str]] = [] + chosen: str | None = None + found: Any = ABSENT + reconciliation: dict[str, Any] | None = None + if "reconcile" in entry: + mode = entry["reconcile"]["mode"] + order = entry["reconcile"].get("precedence", [o["id"] for o in observations]) + reconciliation = {"mode": mode, "observed": [x[0] for x in present]} + if mode == "agree" and present: + found = present[0][1] + if any(not json_equal(found, x[1]) for x in present[1:]): + found = ABSENT + reconciliation["conflict"] = True + findings.append({"code": "conflict"}) + else: + reconciliation["agreed"] = True + else: + by_id = dict(present) + chosen = next((name for name in order if name in by_id), None) + if chosen is not None: + found = by_id[chosen] + reconciliation["chosen"] = chosen + elif present: + chosen, found = present[0] + if found is ABSENT and entry.get("required") and not findings: + findings.append({"code": "missing-required"}) + if found is not ABSENT and not _quality_type(found, entry.get("type", "string")): + findings.append({"code": "wrong-type"}) + found = ABSENT + domain = entry.get("domain") + if found is not ABSENT and found is not None and domain is not None: + inside = any(json_equal(found, x) for x in domain["in"]) if "in" in domain else ( + isinstance(found, str) and DECIMAL_TEXT.fullmatch(found) is not None + and Decimal(domain["decimalRange"]["min"]) <= Decimal(found) <= Decimal(domain["decimalRange"]["max"]) + ) + if not inside: + findings.append({"code": "out-of-domain"}) + found = ABSENT + if transformations is not None and (set(entry) - {"target", "source"}): + record: dict[str, Any] = {"applications": applications, "findings": findings} + if reconciliation is not None: + record["reconciliation"] = reconciliation + transformations[entry["target"]] = record if found is ABSENT: continue if not in_canon_domain(found): @@ -2482,6 +2821,7 @@ class Outcome: # reconciliation (§6.4) citations: list[dict[str, Any]] | None = None record: Any = None + transformations: dict[str, Any] = field(default_factory=dict) @dataclass @@ -2533,7 +2873,7 @@ def recompute( case_scope = dict(case_values) case_scope["runAt"] = verified_at case_outcome = Outcome("case", None, dialect.case_read, "asserted", False, case_scope) - case_outcome.claim = copy_claim(dialect.case_read.copy or {}, case) + case_outcome.claim = copy_claim(dialect.case_read.copy or {}, case, case_outcome.transformations) outcomes.append(case_outcome) receipts: list[dict[str, Any]] = [] @@ -2596,7 +2936,7 @@ def recompute( } continue if source.read.copy is not None: - outcome.claim = copy_claim(source.read.copy, value) + outcome.claim = copy_claim(source.read.copy, value, outcome.transformations) else: assert source.read.rule is not None outcome.claim = rule_claim(source.read.rule, value, values) @@ -2711,7 +3051,7 @@ def research_claim(source: Source, outcome: Outcome, outcomes: list[Outcome]) -> if value_depth(value) > MAX_VALUE_DEPTH: raise Refused("input-over-bound", f"{source.name}: the combined value nests more than 32 levels") if read.copy is not None: - return copy_claim(read.copy, value) + return copy_claim(read.copy, value, outcome.transformations) assert read.rule is not None return rule_claim(read.rule, value, outcome.parameters) @@ -2822,6 +3162,8 @@ def build(dialect: Dialect, outcomes: list[Outcome], verified_at: str) -> Comput entry["receipts"] = list(outcome.citations) if outcome.answer is not None: entry["calculation"] = calculation_lineage(dialect, outcome) + if kind == "fact" and target in outcome.transformations: + entry["transformation"] = outcome.transformations[target] lineage.append(entry) if kind == "fact" and present: write_at(facts_document, target, resolve(claim["facts"], target)) From 336b354a1bc341251a57b486859b7a1e6e784cdc Mon Sep 17 00:00:00 2001 From: kikashy Date: Thu, 22 Oct 2026 15:24:33 -0400 Subject: [PATCH 2/4] Add the dialect quality and research-result vectors named by the build (#170, #169) Signed-off-by: kikashy --- conformance/dialect/quality-vectors.json | 33 +++++++++++ .../dialect/research-result-vectors.json | 59 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 conformance/dialect/quality-vectors.json create mode 100644 conformance/dialect/research-result-vectors.json diff --git a/conformance/dialect/quality-vectors.json b/conformance/dialect/quality-vectors.json new file mode 100644 index 0000000..953faaf --- /dev/null +++ b/conformance/dialect/quality-vectors.json @@ -0,0 +1,33 @@ +{ + "status": "staged", + "cases": [ + {"id":"gtm-decimal-dot","entry":{"target":"/value","source":"/raw","type":"decimal","required":true,"convert":{"kind":"decimal-from-text","decimalSeparator":".","thousandsSeparator":",","sign":"minus","whitespace":"reject"}},"input":{"raw":"1,234.50"},"expected":"1234.50"}, + {"id":"decimal-comma","entry":{"target":"/value","source":"/raw","type":"decimal","required":true,"convert":{"kind":"decimal-from-text","decimalSeparator":",","thousandsSeparator":".","sign":"plus-minus","whitespace":"trim"}},"input":{"raw":" +1.234,50 "},"expected":"1234.50"}, + {"id":"decimal-number","entry":{"target":"/value","source":"/raw","type":"decimal","required":true,"convert":{"kind":"decimal-from-number"}},"input":{"raw":1.25e2},"expected":"125"}, + {"id":"work-item-boolean","entry":{"target":"/value","source":"/raw","type":"boolean","required":true,"convert":{"kind":"boolean-from-text","true":["yes","Y"],"false":["no","N"]}},"input":{"raw":"N"},"expected":false}, + {"id":"null-preserved","entry":{"target":"/value","source":"/raw","type":"boolean","required":true,"convert":{"kind":"boolean-from-text","true":["yes"],"false":["no"]}},"input":{"raw":null},"expected":null}, + {"id":"missing-preserved","entry":{"target":"/value","source":"/raw","type":"string","required":true,"convert":{"kind":"trim"}},"input":{},"expectedFinding":"missing-required"}, + {"id":"trim","entry":{"target":"/value","source":"/raw","type":"string","required":true,"convert":{"kind":"trim"}},"input":{"raw":"\u0085value\u3000"},"expected":"value"}, + {"id":"non-whitespace-bom","entry":{"target":"/value","source":"/raw","type":"string","required":true,"convert":{"kind":"trim"}},"input":{"raw":"\ufeffvalue\ufeff"},"expected":"\ufeffvalue\ufeff"}, + {"id":"collapse-whitespace","entry":{"target":"/value","source":"/raw","type":"string","required":true,"convert":{"kind":"collapse-whitespace"}},"input":{"raw":"a b\n c"},"expected":"a b c"}, + {"id":"date","entry":{"target":"/value","source":"/raw","type":"date","required":true,"convert":{"kind":"date-from-text","format":"DD/MM/YYYY"}},"input":{"raw":"22/10/2026"},"expected":"2026-10-22"}, + {"id":"date-iso","entry":{"target":"/value","source":"/raw","type":"date","required":true,"convert":{"kind":"date-from-text","format":"YYYY-MM-DD"}},"input":{"raw":"2026-10-22"},"expected":"2026-10-22"}, + {"id":"date-us","entry":{"target":"/value","source":"/raw","type":"date","required":true,"convert":{"kind":"date-from-text","format":"MM/DD/YYYY"}},"input":{"raw":"10/22/2026"},"expected":"2026-10-22"}, + {"id":"invalid-encoding","entry":{"target":"/value","source":"/raw","type":"date","required":true,"convert":{"kind":"date-from-text","format":"YYYY-MM-DD"}},"input":{"raw":"2026-02-30"},"expectedFinding":"missing-required","expectedFailure":"invalid-encoding"}, + {"id":"boolean-out-of-domain","entry":{"target":"/value","source":"/raw","type":"boolean","required":true,"convert":{"kind":"boolean-from-text","true":["yes"],"false":["no"]}},"input":{"raw":"maybe"},"expectedFinding":"missing-required","expectedFailure":"out-of-domain"}, + {"id":"conversion-failure","entry":{"target":"/value","source":"/raw","type":"decimal","required":true,"convert":{"kind":"decimal-from-text","decimalSeparator":".","thousandsSeparator":",","sign":"none","whitespace":"reject"}},"input":{"raw":"not a number"},"expectedFinding":"missing-required","expectedFailure":"not-a-number"}, + {"id":"precedence","entry":{"target":"/value","observations":[{"id":"primary","source":"/a"},{"id":"secondary","source":"/b"}],"reconcile":{"mode":"precedence","precedence":["secondary","primary"]},"type":"string","required":true},"input":{"a":"first","b":"second"},"expected":"second","expectedChosen":"secondary"}, + {"id":"first-present","entry":{"target":"/value","observations":[{"id":"primary","source":"/a"},{"id":"secondary","source":"/b"}],"reconcile":{"mode":"first-present"},"type":"string","required":true},"input":{"b":"second"},"expected":"second","expectedChosen":"secondary"}, + {"id":"agree","entry":{"target":"/value","observations":[{"id":"one","source":"/a"},{"id":"two","source":"/b"}],"reconcile":{"mode":"agree"},"type":"boolean","required":true},"input":{"a":false,"b":false},"expected":false}, + {"id":"agree-conflict","entry":{"target":"/value","observations":[{"id":"one","source":"/a"},{"id":"two","source":"/b"}],"reconcile":{"mode":"agree"},"type":"boolean","required":true},"input":{"a":false,"b":true},"expectedFinding":"conflict"}, + {"id":"domain","entry":{"target":"/value","source":"/raw","type":"decimal","required":true,"domain":{"decimalRange":{"min":"0","max":"10"}}},"input":{"raw":"11"},"expectedFinding":"out-of-domain"}, + {"id":"reversed-domain","entry":{"target":"/value","source":"/raw","type":"decimal","required":true,"domain":{"decimalRange":{"min":"10","max":"0"}}},"input":{"raw":"5"},"expectedRefusal":"input-malformed"}, + {"id":"domain-in","entry":{"target":"/value","source":"/raw","type":"string","required":true,"domain":{"in":["open","closed"]}},"input":{"raw":"open"},"expected":"open"}, + {"id":"wrong-type","entry":{"target":"/value","source":"/raw","type":"boolean","required":true},"input":{"raw":"false"},"expectedFinding":"wrong-type"}, + {"id":"empty-conversion-rule","entry":{"target":"/value","source":"/raw","type":"boolean","required":true,"convert":{"kind":"boolean-from-text","true":[],"false":["no"]}},"input":{"raw":"no"},"expectedRefusal":"input-malformed"}, + {"id":"missing-conversion-rule-member","entry":{"target":"/value","source":"/raw","type":"decimal","required":true,"convert":{"kind":"decimal-from-text","decimalSeparator":".","thousandsSeparator":",","whitespace":"reject"}},"input":{"raw":"1"},"expectedRefusal":"input-malformed"}, + {"id":"observations-over-bound","entry":{"target":"/value","observations":[{"id":"s01","source":"/s01"},{"id":"s02","source":"/s02"},{"id":"s03","source":"/s03"},{"id":"s04","source":"/s04"},{"id":"s05","source":"/s05"},{"id":"s06","source":"/s06"},{"id":"s07","source":"/s07"},{"id":"s08","source":"/s08"},{"id":"s09","source":"/s09"},{"id":"s10","source":"/s10"},{"id":"s11","source":"/s11"},{"id":"s12","source":"/s12"},{"id":"s13","source":"/s13"},{"id":"s14","source":"/s14"},{"id":"s15","source":"/s15"},{"id":"s16","source":"/s16"},{"id":"s17","source":"/s17"}],"reconcile":{"mode":"first-present"},"type":"string","required":true},"input":{},"expectedRefusal":"input-over-bound"}, + {"id":"unknown-conversion","entry":{"target":"/value","source":"/raw","type":"string","required":true,"convert":{"kind":"script"}},"input":{"raw":"x"},"expectedRefusal":"input-malformed"}, + {"id":"last-writer-wins","entry":{"target":"/value","observations":[{"id":"one","source":"/a"},{"id":"two","source":"/b"}],"reconcile":{"mode":"last-writer-wins"},"type":"string","required":true},"input":{"a":"x","b":"y"},"expectedRefusal":"input-malformed"} + ] +} diff --git a/conformance/dialect/research-result-vectors.json b/conformance/dialect/research-result-vectors.json new file mode 100644 index 0000000..3743c88 --- /dev/null +++ b/conformance/dialect/research-result-vectors.json @@ -0,0 +1,59 @@ +{ + "status": "staged", + "dialect": { + "version": 3, + "case": {"facts": [], "evidence": []}, + "sources": [{ + "name": "research", + "kind": "research", + "profile": "generated", + "profileDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "webProfiles": {}, + "maxAge": 300, + "question": "Summarize the retained material.", + "constraints": {}, + "model": "portable-model", + "evidence": ["Retain the response bytes."], + "results": [ + {"id":"summary","type":"string","required":true,"description":"A concise summary.","cardinality":"one"}, + {"id":"score","type":"decimal","required":true,"description":"An exact score.","cardinality":"one"}, + {"id":"flags","type":"boolean","required":true,"description":"Boolean flags.","cardinality":"many"}, + {"id":"tags","type":"string-list","required":true,"description":"Ordered tags.","cardinality":"many"}, + {"id":"decision","type":"boolean","required":true,"description":"Conflicting decisions.","cardinality":"one"}, + {"id":"access","type":"string","required":true,"description":"An inaccessible observation.","cardinality":"one"}, + {"id":"old","type":"string","required":true,"description":"A stale observation.","cardinality":"one"} + ], + "acquisitionSources": [{"profile":"public-pages","version":"1","purpose":"discovery","scope":{"origins":["https://example.invalid"]},"freshness":86400}], + "capabilities": ["answer"], + "read": {"copy":{"facts":[{"target":"/research/state","source":"/results/summary/status","type":"string","required":true,"convert":{"kind":"trim"}}],"evidence":[]}} + }] + }, + "cases": [ + { + "id": "requested-observation-and-retained-replay", + "record": { + "results":{ + "summary":{"status":"missing","cites":[],"reason":"No retained page answered the request."}, + "score":{"status":"present","cites":[0],"value":"1.00"}, + "flags":{"status":"present","cites":[0],"value":[false,true]}, + "tags":{"status":"present","cites":[0],"value":[["reviewed","portable"],["public"]]}, + "decision":{"status":"conflicting","cites":[],"values":[{"value":false,"cites":[0]},{"value":true,"cites":[0]}]}, + "access":{"status":"inaccessible","cites":[],"reason":"The source denied access."}, + "old":{"status":"stale","cites":[],"reason":"The observation exceeded its freshness declaration."} + }, + "responses":[{"profile":{"id":"generated","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000"},"request":{},"result":{},"receipt":{},"salts":{"args":"0000000000000000000000000000000000000000000000000000000000000000"}}], + "execution":{"resolvedSettings":{"model":"resolved-model-v1","profile":"generated-default","version":"1","settings":{"temperature":"0"}},"retainedBytes":[{"mediaType":"application/json","base64":"e30="}]} + }, + "expected": "accepted" + }, + { + "id": "result-outside-contract", + "record": { + "results":{"summary":{"status":"missing","cites":[],"reason":"No answer."},"undeclared":{"status":"missing","cites":[],"reason":"Not requested."}}, + "responses":[], + "execution":{"resolvedSettings":{"model":"resolved-model-v1","profile":"generated-default","version":"1","settings":{}},"retainedBytes":[]} + }, + "expected": "input-malformed" + } + ] +} From ca21e48c50518bddc7dd8780daa6479ab2e91fb5 Mon Sep 17 00:00:00 2001 From: kikashy Date: Thu, 22 Oct 2026 15:46:44 -0400 Subject: [PATCH 3/4] Fix dialect data-quality review findings Make both dialect readers enforce exact date widths, decimal expansion and grouping, typed research states, and null quality findings identically. Commit retained research bytes and resolved settings to preparation lineage so public replay detects changed bytes, and add shared positive and refusal vectors for every corrected behavior. Co-Authored-By: Codex Signed-off-by: kikashy --- conformance/dialect/README.md | 7 +- conformance/dialect/quality-vectors.json | 9 +- .../dialect/research-result-vectors.json | 4 + rfcs/0018-dialect.md | 41 +++++++-- spec/dialect.md | 39 +++++++-- tests/test_dialect.py | 28 +++++++ tools/dialect-verify-ts/src/forms.ts | 9 +- tools/dialect-verify-ts/src/prepare.ts | 42 ++++++++-- tools/dialect-verify-ts/test/units.test.ts | 40 ++++++++- tools/dialect_verify.py | 84 ++++++++++++++++--- 10 files changed, 266 insertions(+), 37 deletions(-) diff --git a/conformance/dialect/README.md b/conformance/dialect/README.md index 7f576da..2d5f151 100644 --- a/conformance/dialect/README.md +++ b/conformance/dialect/README.md @@ -72,8 +72,11 @@ cases for the 2026-10-22 amendment, not Runner recordings and not entries in the manifest. The first covers every conversion kind, conversion failure, reconciliation mode and quality finding, including go-to-market and work-item examples. The second covers the typed result contract, every observation state, versioned acquisition-source declarations, resolved execution -settings and retained bytes. Both readers consume these same files; paired reader tests add the -empty, missing and one-over-bound mutations. No model or web request was made to write either file. +settings and retained bytes. [`research-replay-vector.json`](research-replay-vector.json) is the +complete public-verifier case: its hand-written expected preparation commits the execution settings +and decoded retained bytes in lineage, and its changed-bytes twin gives `preparation-differs`. +Both readers consume these same files; paired reader tests add the empty, missing and one-over-bound +mutations. No model or web request was made to write any of them. ## What a vector holds, and the answer a verifier gives diff --git a/conformance/dialect/quality-vectors.json b/conformance/dialect/quality-vectors.json index 953faaf..264f95f 100644 --- a/conformance/dialect/quality-vectors.json +++ b/conformance/dialect/quality-vectors.json @@ -4,8 +4,13 @@ {"id":"gtm-decimal-dot","entry":{"target":"/value","source":"/raw","type":"decimal","required":true,"convert":{"kind":"decimal-from-text","decimalSeparator":".","thousandsSeparator":",","sign":"minus","whitespace":"reject"}},"input":{"raw":"1,234.50"},"expected":"1234.50"}, {"id":"decimal-comma","entry":{"target":"/value","source":"/raw","type":"decimal","required":true,"convert":{"kind":"decimal-from-text","decimalSeparator":",","thousandsSeparator":".","sign":"plus-minus","whitespace":"trim"}},"input":{"raw":" +1.234,50 "},"expected":"1234.50"}, {"id":"decimal-number","entry":{"target":"/value","source":"/raw","type":"decimal","required":true,"convert":{"kind":"decimal-from-number"}},"input":{"raw":1.25e2},"expected":"125"}, + {"id":"decimal-number-exponent-shift","entry":{"target":"/value","source":"/raw","type":"decimal","required":true,"convert":{"kind":"decimal-from-number"}},"input":{"raw":0.01e3},"expected":"10"}, + {"id":"decimal-number-negative-zero","entry":{"target":"/value","source":"/raw","type":"decimal","required":true,"convert":{"kind":"decimal-from-number"}},"input":{"raw":-0e2},"expected":"0"}, {"id":"work-item-boolean","entry":{"target":"/value","source":"/raw","type":"boolean","required":true,"convert":{"kind":"boolean-from-text","true":["yes","Y"],"false":["no","N"]}},"input":{"raw":"N"},"expected":false}, - {"id":"null-preserved","entry":{"target":"/value","source":"/raw","type":"boolean","required":true,"convert":{"kind":"boolean-from-text","true":["yes"],"false":["no"]}},"input":{"raw":null},"expected":null}, + {"id":"null-string","entry":{"target":"/value","source":"/raw","type":"string","required":true},"input":{"raw":null},"expectedFinding":"wrong-type"}, + {"id":"null-decimal","entry":{"target":"/value","source":"/raw","type":"decimal","required":true},"input":{"raw":null},"expectedFinding":"wrong-type"}, + {"id":"null-boolean","entry":{"target":"/value","source":"/raw","type":"boolean","required":true,"convert":{"kind":"boolean-from-text","true":["yes"],"false":["no"]}},"input":{"raw":null},"expectedFinding":"wrong-type"}, + {"id":"null-date","entry":{"target":"/value","source":"/raw","type":"date","required":true},"input":{"raw":null},"expectedFinding":"wrong-type"}, {"id":"missing-preserved","entry":{"target":"/value","source":"/raw","type":"string","required":true,"convert":{"kind":"trim"}},"input":{},"expectedFinding":"missing-required"}, {"id":"trim","entry":{"target":"/value","source":"/raw","type":"string","required":true,"convert":{"kind":"trim"}},"input":{"raw":"\u0085value\u3000"},"expected":"value"}, {"id":"non-whitespace-bom","entry":{"target":"/value","source":"/raw","type":"string","required":true,"convert":{"kind":"trim"}},"input":{"raw":"\ufeffvalue\ufeff"},"expected":"\ufeffvalue\ufeff"}, @@ -13,9 +18,11 @@ {"id":"date","entry":{"target":"/value","source":"/raw","type":"date","required":true,"convert":{"kind":"date-from-text","format":"DD/MM/YYYY"}},"input":{"raw":"22/10/2026"},"expected":"2026-10-22"}, {"id":"date-iso","entry":{"target":"/value","source":"/raw","type":"date","required":true,"convert":{"kind":"date-from-text","format":"YYYY-MM-DD"}},"input":{"raw":"2026-10-22"},"expected":"2026-10-22"}, {"id":"date-us","entry":{"target":"/value","source":"/raw","type":"date","required":true,"convert":{"kind":"date-from-text","format":"MM/DD/YYYY"}},"input":{"raw":"10/22/2026"},"expected":"2026-10-22"}, + {"id":"date-width","entry":{"target":"/value","source":"/raw","type":"date","required":true,"convert":{"kind":"date-from-text","format":"MM/DD/YYYY"}},"input":{"raw":"1/2/2026"},"expectedFinding":"missing-required","expectedFailure":"invalid-encoding"}, {"id":"invalid-encoding","entry":{"target":"/value","source":"/raw","type":"date","required":true,"convert":{"kind":"date-from-text","format":"YYYY-MM-DD"}},"input":{"raw":"2026-02-30"},"expectedFinding":"missing-required","expectedFailure":"invalid-encoding"}, {"id":"boolean-out-of-domain","entry":{"target":"/value","source":"/raw","type":"boolean","required":true,"convert":{"kind":"boolean-from-text","true":["yes"],"false":["no"]}},"input":{"raw":"maybe"},"expectedFinding":"missing-required","expectedFailure":"out-of-domain"}, {"id":"conversion-failure","entry":{"target":"/value","source":"/raw","type":"decimal","required":true,"convert":{"kind":"decimal-from-text","decimalSeparator":".","thousandsSeparator":",","sign":"none","whitespace":"reject"}},"input":{"raw":"not a number"},"expectedFinding":"missing-required","expectedFailure":"not-a-number"}, + {"id":"malformed-grouping","entry":{"target":"/value","source":"/raw","type":"decimal","required":true,"convert":{"kind":"decimal-from-text","decimalSeparator":".","thousandsSeparator":",","sign":"none","whitespace":"reject"}},"input":{"raw":"1,,2"},"expectedFinding":"missing-required","expectedFailure":"not-a-number"}, {"id":"precedence","entry":{"target":"/value","observations":[{"id":"primary","source":"/a"},{"id":"secondary","source":"/b"}],"reconcile":{"mode":"precedence","precedence":["secondary","primary"]},"type":"string","required":true},"input":{"a":"first","b":"second"},"expected":"second","expectedChosen":"secondary"}, {"id":"first-present","entry":{"target":"/value","observations":[{"id":"primary","source":"/a"},{"id":"secondary","source":"/b"}],"reconcile":{"mode":"first-present"},"type":"string","required":true},"input":{"b":"second"},"expected":"second","expectedChosen":"secondary"}, {"id":"agree","entry":{"target":"/value","observations":[{"id":"one","source":"/a"},{"id":"two","source":"/b"}],"reconcile":{"mode":"agree"},"type":"boolean","required":true},"input":{"a":false,"b":false},"expected":false}, diff --git a/conformance/dialect/research-result-vectors.json b/conformance/dialect/research-result-vectors.json index 3743c88..c240279 100644 --- a/conformance/dialect/research-result-vectors.json +++ b/conformance/dialect/research-result-vectors.json @@ -55,5 +55,9 @@ }, "expected": "input-malformed" } + ], + "mutations": [ + {"id":"typed-result-refuses-found","field":"score","status":"found","cites":[0],"value":"1.00","expected":"input-malformed"}, + {"id":"typed-result-refuses-not-found","field":"score","status":"not-found","expected":"input-malformed"} ] } diff --git a/rfcs/0018-dialect.md b/rfcs/0018-dialect.md index a35164d..433e0cc 100644 --- a/rfcs/0018-dialect.md +++ b/rfcs/0018-dialect.md @@ -950,7 +950,7 @@ coerced fact. `precedence`, `agree` and `first-present` are the only reconciliat last-writer-wins remains forbidden by the target-ownership rule. The amendment also gives `research` the `results` typed-contract spelling and explicit observation -states, portable versioned acquisition-source profiles, and optional execution evidence containing +states, portable versioned acquisition-source profiles, and required execution evidence containing the resolved model/profile/settings and retained response bytes. Installation bindings continue to own credentials, local connection ids, retries, concurrency, allowances and approvals. Replay validates the retained bytes and applies the ordinary reads and targets without calling a model or @@ -1428,6 +1428,8 @@ description form; and `cardinality` is `one` or `many`. The research record then instead of `fields`, with the same ids and the explicit states `present`, `missing`, `conflicting`, `inaccessible` and `stale`. `present` carries `value` and citations; `conflicting` carries the typed `values` with their citations; every other state carries a non-empty `reason` and no value. The +legacy states `found` and `not-found` are `input-malformed` in `results`; conversely, `fields` +continues to admit its legacy states and does not admit `present`, `missing` or `inaccessible`. The legacy `fields` spelling above remains readable in version 3 for the staged Runner vectors. Either spelling is the research kind's typed result contract: identifier, description, exact type and encoding, required presence and cardinality are declared before execution. A value or list outside @@ -1478,6 +1480,13 @@ retained bytes and the result contract, then applies the ordinary read and targe observation becomes a consumed fact only when the research read names its `/results/` state or value and writes a target; a declaration alone writes no fact. +Each lineage entry for an acquired `results` research source has `researchExecution`, exactly +`resolvedSettings` and `retainedBytes`. The former repeats the execution record's closed resolved +settings. The latter preserves array order and maps every retained byte entry to exactly +`{"mediaType": M, "digest": H}`, where `H` is the SHA-256 digest of the decoded base64 bytes. +Thus different retained bytes change the recomputed preparation even when the typed result is +unchanged; replay with the earlier preparation gives `preparation-differs` and performs no call. + `R.fields` or `R.results`, every status, value, observation and citation index, and `R.responses` in its order are all in `R`'s `canon` bytes. The record's receipt commits all of them when present; without one, results as well as response membership and order are the caller's assertion. The preparation @@ -1909,7 +1918,7 @@ observation before reconciliation: | Kind | Other rule members | Result | | --- | --- | --- | -| `decimal-from-text` | `decimalSeparator`, `.` or `,`; `thousandsSeparator`, a different string; `sign`, `none`, `minus` or `plus-minus`; `whitespace`, `reject` or `trim` | a Core §2.2 decimal string | +| `decimal-from-text` | `decimalSeparator`, `.` or `,`; `thousandsSeparator`, empty or the other one of `.` and `,`; `sign`, `none`, `minus` or `plus-minus`; `whitespace`, `reject` or `trim` | a Core §2.2 decimal string | | `decimal-from-number` | none | the JSON number literal rendered without an exponent as the mathematically equal Core §2.2 decimal string | | `boolean-from-text` | non-empty, disjoint `true` and `false` spelling arrays, each of at most 16 distinct strings | the corresponding JSON boolean | | `trim` | none | the string without leading or trailing Unicode whitespace | @@ -1920,10 +1929,29 @@ Here whitespace is exactly U+0009–U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000–U+200A, U+2028, U+2029, U+202F, U+205F and U+3000; no host-language whitespace class extends that set. +For `decimal-from-number`, split the JSON literal into sign, integer digits, fraction digits and +base-ten exponent. Concatenate the integer and fraction digits, move the decimal point from after +the original integer digits by the signed exponent, inserting zeroes on the side crossed. Strip +leading zeroes from the resulting integer part, retaining one, and trailing zeroes from the +fraction; omit an empty fraction. A zero result is exactly `0`, including for every negative-zero +literal. No binary floating-point operation participates. + +For `decimal-from-text`, after the declared whitespace and sign rules, the integer part is either +ungrouped `0` or `[1-9][0-9]*`, or `[1-9][0-9]{0,2}` followed by one or more groups each exactly +the non-empty `thousandsSeparator` and three ASCII digits. The optional decimal part is exactly +`decimalSeparator` followed by one or more ASCII digits. The separator occurs nowhere else; +therefore `1,,2`, misplaced groups and separators in the fraction are `not-a-number`. +`date-from-text` checks the declared lexical form before the calendar date: four, two and two +ASCII digits in the positions of `YYYY-MM-DD`, and exactly two, two and four in those of +`MM/DD/YYYY` or `DD/MM/YYYY`. A width mismatch is `invalid-encoding` even when a host parser would +accept it. + No other kind is admitted; an operation outside this table is upstream preparation, not a dialect -conversion. Missing stays missing and `null` stays `null`. A rule applied to another JSON type or -an invalid date has failure `invalid-encoding`; a decimal input that does not name a number has -`not-a-number`; a boolean spelling outside both lists has `out-of-domain`. A failed conversion +conversion. Missing stays missing. A conversion carries `null` unchanged to the type check; since +`null` is outside every quality `type`, it records `wrong-type` and yields no fact. A rule applied +to another JSON type or an invalid date has failure `invalid-encoding`; a decimal input that does +not name a number has `not-a-number`; a boolean spelling outside both lists has `out-of-domain`. +A failed conversion produces no candidate and never a coerced value. `reconcile` is exactly `{"mode": M}` for `agree` and `first-present`, or @@ -1942,7 +1970,8 @@ value records `missing-required`; a value not of `type` records `wrong-type`; a `domain` records `out-of-domain`. These are closed quality findings, as is `conflict`. Their target is omitted from the facts document: to the pack it is an unknown fact, and the pack's `onUnknown`, not the dialect, decides the consequence. Zero, `false` and the empty string are present. A `null` -candidate remains `null` and is neither silently repaired nor treated as missing. +candidate is present input but outside all four declared types: it records `wrong-type`, is omitted +from the facts document and is never silently repaired. For every quality target, its lineage entry has `transformation`, exactly `applications` and `findings`, and `reconciliation` when declared. Each application records its observation `id`, diff --git a/spec/dialect.md b/spec/dialect.md index 3a7bd8b..175d737 100644 --- a/spec/dialect.md +++ b/spec/dialect.md @@ -582,6 +582,8 @@ description form; and `cardinality` is `one` or `many`. The research record then instead of `fields`, with the same ids and the explicit states `present`, `missing`, `conflicting`, `inaccessible` and `stale`. `present` carries `value` and citations; `conflicting` carries the typed `values` with their citations; every other state carries a non-empty `reason` and no value. The +legacy states `found` and `not-found` are `input-malformed` in `results`; conversely, `fields` +continues to admit its legacy states and does not admit `present`, `missing` or `inaccessible`. The legacy `fields` spelling above remains readable in version 3 for the staged Runner vectors. Either spelling is the research kind's typed result contract: identifier, description, exact type and encoding, required presence and cardinality are declared before execution. A value or list outside @@ -632,6 +634,13 @@ retained bytes and the result contract, then applies the ordinary read and targe observation becomes a consumed fact only when the research read names its `/results/` state or value and writes a target; a declaration alone writes no fact. +Each lineage entry for an acquired `results` research source has `researchExecution`, exactly +`resolvedSettings` and `retainedBytes`. The former repeats the execution record's closed resolved +settings. The latter preserves array order and maps every retained byte entry to exactly +`{"mediaType": M, "digest": H}`, where `H` is the SHA-256 digest of the decoded base64 bytes. +Thus different retained bytes change the recomputed preparation even when the typed result is +unchanged; replay with the earlier preparation gives `preparation-differs` and performs no call. + `R.fields` or `R.results`, every status, value, observation and citation index, and `R.responses` in its order are all in `R`'s `canon` bytes. The record's receipt commits all of them when present; without one, results as well as response membership and order are the caller's assertion. The preparation @@ -1063,7 +1072,7 @@ observation before reconciliation: | Kind | Other rule members | Result | | --- | --- | --- | -| `decimal-from-text` | `decimalSeparator`, `.` or `,`; `thousandsSeparator`, a different string; `sign`, `none`, `minus` or `plus-minus`; `whitespace`, `reject` or `trim` | a Core §2.2 decimal string | +| `decimal-from-text` | `decimalSeparator`, `.` or `,`; `thousandsSeparator`, empty or the other one of `.` and `,`; `sign`, `none`, `minus` or `plus-minus`; `whitespace`, `reject` or `trim` | a Core §2.2 decimal string | | `decimal-from-number` | none | the JSON number literal rendered without an exponent as the mathematically equal Core §2.2 decimal string | | `boolean-from-text` | non-empty, disjoint `true` and `false` spelling arrays, each of at most 16 distinct strings | the corresponding JSON boolean | | `trim` | none | the string without leading or trailing Unicode whitespace | @@ -1074,10 +1083,29 @@ Here whitespace is exactly U+0009–U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000–U+200A, U+2028, U+2029, U+202F, U+205F and U+3000; no host-language whitespace class extends that set. +For `decimal-from-number`, split the JSON literal into sign, integer digits, fraction digits and +base-ten exponent. Concatenate the integer and fraction digits, move the decimal point from after +the original integer digits by the signed exponent, inserting zeroes on the side crossed. Strip +leading zeroes from the resulting integer part, retaining one, and trailing zeroes from the +fraction; omit an empty fraction. A zero result is exactly `0`, including for every negative-zero +literal. No binary floating-point operation participates. + +For `decimal-from-text`, after the declared whitespace and sign rules, the integer part is either +ungrouped `0` or `[1-9][0-9]*`, or `[1-9][0-9]{0,2}` followed by one or more groups each exactly +the non-empty `thousandsSeparator` and three ASCII digits. The optional decimal part is exactly +`decimalSeparator` followed by one or more ASCII digits. The separator occurs nowhere else; +therefore `1,,2`, misplaced groups and separators in the fraction are `not-a-number`. +`date-from-text` checks the declared lexical form before the calendar date: four, two and two +ASCII digits in the positions of `YYYY-MM-DD`, and exactly two, two and four in those of +`MM/DD/YYYY` or `DD/MM/YYYY`. A width mismatch is `invalid-encoding` even when a host parser would +accept it. + No other kind is admitted; an operation outside this table is upstream preparation, not a dialect -conversion. Missing stays missing and `null` stays `null`. A rule applied to another JSON type or -an invalid date has failure `invalid-encoding`; a decimal input that does not name a number has -`not-a-number`; a boolean spelling outside both lists has `out-of-domain`. A failed conversion +conversion. Missing stays missing. A conversion carries `null` unchanged to the type check; since +`null` is outside every quality `type`, it records `wrong-type` and yields no fact. A rule applied +to another JSON type or an invalid date has failure `invalid-encoding`; a decimal input that does +not name a number has `not-a-number`; a boolean spelling outside both lists has `out-of-domain`. +A failed conversion produces no candidate and never a coerced value. `reconcile` is exactly `{"mode": M}` for `agree` and `first-present`, or @@ -1096,7 +1124,8 @@ value records `missing-required`; a value not of `type` records `wrong-type`; a `domain` records `out-of-domain`. These are closed quality findings, as is `conflict`. Their target is omitted from the facts document: to the pack it is an unknown fact, and the pack's `onUnknown`, not the dialect, decides the consequence. Zero, `false` and the empty string are present. A `null` -candidate remains `null` and is neither silently repaired nor treated as missing. +candidate is present input but outside all four declared types: it records `wrong-type`, is omitted +from the facts document and is never silently repaired. For every quality target, its lineage entry has `transformation`, exactly `applications` and `findings`, and `reconciliation` when declared. Each application records its observation `id`, diff --git a/tests/test_dialect.py b/tests/test_dialect.py index 068ee86..9cb0161 100644 --- a/tests/test_dialect.py +++ b/tests/test_dialect.py @@ -2737,6 +2737,16 @@ def test_the_hand_written_research_result_vectors(self) -> None: claim = READER.copy_claim(dialect.sources[0].read.copy, row["record"], transformations) self.assertEqual("missing", READER.resolve(claim["facts"], "/research/state")) self.assertEqual({"kind": "trim"}, transformations["/research/state"]["applications"][0]["rule"]) + for mutation in document["mutations"]: + with self.subTest(vector=mutation["id"]): + record = copy.deepcopy(document["cases"][0]["record"]) + field = mutation["field"] + entry = {"status": mutation["status"], "cites": mutation.get("cites", [])} + if "value" in mutation: + entry["value"] = mutation["value"] + record["results"][field] = entry + with self.assertRaisesRegex(READER.Refused, mutation["expected"]): + READER.check_acquisition_form("research", {"response": {"result": record}}, research) def test_research_result_refusal_mutations(self) -> None: document = READER.read_document((DIALECT / "research-result-vectors.json").read_bytes(), "research vectors") @@ -2806,6 +2816,24 @@ def test_semantic_declarations_change_the_dialect_digest(self) -> None: self.assertNotEqual(digest(base), digest(changed)) self.assertNotEqual(digest(conversion), digest(changed_conversion)) + def test_public_verifier_replays_retained_research_bytes(self) -> None: + document = READER.read_document((DIALECT / "research-replay-vector.json").read_bytes(), "replay vector") + encoded = lambda name: READER.preparation_bytes(document[name]) + result = READER.verify( + encoded("dialect"), encoded("case"), encoded("acquisitions"), encoded("profiles"), + encoded("preparation"), None, False, None, + ) + self.assertIn("reading", result.answer) + self.assertEqual(encoded("preparation"), result.preparation) + + changed = copy.deepcopy(document["acquisitions"]) + changed["research"]["response"]["result"]["execution"]["retainedBytes"][0]["base64"] = document["tamperedBase64"] + result = READER.verify( + encoded("dialect"), encoded("case"), READER.preparation_bytes(changed), encoded("profiles"), + encoded("preparation"), None, False, None, + ) + self.assertEqual(document["expectedTampered"], result.answer["finding"]) + class DialectTextTests(unittest.TestCase): def test_the_rfcs_specification_is_the_companion_text(self) -> None: diff --git a/tools/dialect-verify-ts/src/forms.ts b/tools/dialect-verify-ts/src/forms.ts index d5b49ce..43224b9 100644 --- a/tools/dialect-verify-ts/src/forms.ts +++ b/tools/dialect-verify-ts/src/forms.ts @@ -488,7 +488,7 @@ function readConversion(v: JVal | undefined, what: string): JObj { exact(o, what, ["kind", "decimalSeparator", "thousandsSeparator", "sign", "whitespace"]); const decimal = string(o.m.get("decimalSeparator"), `${what}.decimalSeparator`); const thousands = string(o.m.get("thousandsSeparator"), `${what}.thousandsSeparator`); - if (!['.', ','].includes(decimal) || thousands === decimal) malformed(`${what} has invalid separators`); + if (!['.', ','].includes(decimal) || !['', '.', ','].includes(thousands) || thousands === decimal) malformed(`${what} has invalid separators`); if (!["none", "minus", "plus-minus"].includes(string(o.m.get("sign"), `${what}.sign`))) malformed(`${what}.sign is unknown`); if (!["reject", "trim"].includes(string(o.m.get("whitespace"), `${what}.whitespace`))) malformed(`${what}.whitespace is unknown`); } else if (kind === "boolean-from-text") { @@ -1412,7 +1412,7 @@ function readAnswers(v: JVal | undefined, research: Research, responses: number, const type = research.types.get(name)!; const status = member(entry, "status"); const s = status?.t === "str" ? status.v : undefined; - if (s === "found" || s === "present") { + if (s === (research.resultMember === "results" ? "present" : "found")) { const e = exact(entry, w, ["status", "cites", "value"]); cites(e.m.get("cites"), responses, 1, `${w}.cites`); typed(e.m.get("value"), type, `${w}.value`); @@ -1429,8 +1429,9 @@ function readAnswers(v: JVal | undefined, research: Research, responses: number, }); const first = member(values[0], "value")!; if (values.every((x) => jsonEqual(first, member(x, "value")!))) malformed(`${w}.values has no two that differ`); - } else if (s === "not-found" || s === "missing" || s === "inaccessible" || s === "stale") { - const needsReason = research.resultMember === "results" && s !== "not-found"; + } else if ((research.resultMember === "results" && (s === "missing" || s === "inaccessible" || s === "stale")) + || (research.resultMember === "fields" && (s === "not-found" || s === "stale"))) { + const needsReason = research.resultMember === "results"; const e = exact(entry, w, needsReason ? ["status", "cites", "reason"] : ["status", "cites"]); cites(e.m.get("cites"), responses, 0, `${w}.cites`); if (needsReason) nonEmpty(e.m.get("reason"), `${w}.reason`); diff --git a/tools/dialect-verify-ts/src/prepare.ts b/tools/dialect-verify-ts/src/prepare.ts index 2182a6e..2bf286d 100644 --- a/tools/dialect-verify-ts/src/prepare.ts +++ b/tools/dialect-verify-ts/src/prepare.ts @@ -85,8 +85,12 @@ function decimalNumber(literal: string): string { const m = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(literal)!; const digits = m[2] + (m[3] ?? ""); const point = m[2].length + Number(m[4] ?? "0"); - const magnitude = point <= 0 ? `0.${"0".repeat(-point)}${digits}` : point >= digits.length ? digits + "0".repeat(point - digits.length) : `${digits.slice(0, point)}.${digits.slice(point)}`; - return m[1] + magnitude; + let whole = point <= 0 ? "0" : point >= digits.length ? digits + "0".repeat(point - digits.length) : digits.slice(0, point); + let fraction = point <= 0 ? "0".repeat(-point) + digits : point >= digits.length ? "" : digits.slice(point); + whole = whole.replace(/^0+(?=\d)/, ""); + fraction = fraction.replace(/0+$/, ""); + const magnitude = whole + (fraction ? `.${fraction}` : ""); + return magnitude === "0" ? "0" : m[1] + magnitude; } function converted(v: JVal, rule: JObj): { value?: JVal; failure?: string } { @@ -111,9 +115,18 @@ function converted(v: JVal, rule: JObj): { value?: JVal; failure?: string } { const signs = (rule.m.get("sign") as JStr).v; if ((sign === "+" && signs !== "plus-minus") || (sign === "-" && signs === "none")) return { failure: "invalid-encoding" }; const thousands = (rule.m.get("thousandsSeparator") as JStr).v; - if (thousands) text = text.split(thousands).join(""); - text = text.split((rule.m.get("decimalSeparator") as JStr).v).join("."); - const result = (sign === "-" ? "-" : "") + text; + const decimalParts = text.split((rule.m.get("decimalSeparator") as JStr).v); + if (decimalParts.length > 2) return { failure: "not-a-number" }; + const [integer, fraction] = decimalParts; + const digits = (part: string) => /^[0-9]+$/.test(part); + if (fraction !== undefined && !digits(fraction)) return { failure: "not-a-number" }; + const groups = thousands ? integer.split(thousands) : [integer]; + if (groups.length === 1) { + if (!digits(integer) || (integer.length > 1 && integer.startsWith("0"))) return { failure: "not-a-number" }; + } else if (groups[0].length < 1 || groups[0].length > 3 || !digits(groups[0]) || groups[0].startsWith("0") + || groups.slice(1).some((group) => group.length !== 3 || !digits(group))) return { failure: "not-a-number" }; + let result = (sign === "-" ? "-" : "") + groups.join(""); + if (fraction !== undefined) result += `.${fraction}`; return QUALITY_DECIMAL.test(result) ? { value: str(result) } : { failure: "not-a-number" }; } const format = (rule.m.get("format") as JStr).v; @@ -133,7 +146,7 @@ function validDate(year: number, month: number, day: number): boolean { } function qualityType(v: JVal, type: QualityFact["qualityType"]): boolean { - if (v.t === "null") return true; + if (v.t === "null") return false; if (type === "string") return v.t === "str"; if (type === "decimal") return v.t === "str" && QUALITY_DECIMAL.test(v.v); if (type === "boolean") return v.t === "bool"; @@ -531,6 +544,22 @@ function researchClaim(s: Source, outcome: Outcome, outcomes: Outcome[]): Claim return s.read.copy ? applyCopy(s.read.copy, value, s.name, outcome.transformations) : applyRule(s.read.rule!, value, outcome.scope, s.name); } +function researchExecutionLineage(record: JVal): JObj { + const execution = member(record, "execution") as JObj; + const retained = member(execution, "retainedBytes") as JArr; + return obj([ + ["resolvedSettings", member(execution, "resolvedSettings")!], + ["retainedBytes", arr(retained.a.map((value) => { + const item = value as JObj; + const decoded = Uint8Array.from(Buffer.from((member(item, "base64") as JStr).v, "base64")); + return obj([ + ["mediaType", member(item, "mediaType")!], + ["digest", str(`sha256:${sha256hex(decoded)}`)], + ]); + }))], + ]); +} + /** §5.2, the times: the instants check 1 found present (`checkReceipt`), so `acquisition-stale` names only one * outside the window. */ function checkTimes(times: Times, maxAge: number, at: number, who: string): void { @@ -792,6 +821,7 @@ export function recompute(d: Dialect, caseDoc: JVal, acquisitions: Map 0) { const parameters = [...o.source.parameters].sort((a, b) => diff --git a/tools/dialect-verify-ts/test/units.test.ts b/tools/dialect-verify-ts/test/units.test.ts index 368f6f2..d4b24c0 100644 --- a/tools/dialect-verify-ts/test/units.test.ts +++ b/tools/dialect-verify-ts/test/units.test.ts @@ -14,7 +14,7 @@ import { add, decodePoint, encodePoint, keyAcceptable, smallOrder, verify as edV import { Refusal, bindDialect, countsWithin, readAcquisitions, readBinding, readDialect, readQualityFact } from "../src/forms.ts"; import { NotRead, arr, canon, isIntegerLiteral, jsonEqual, member, num, obj, parse, preparationForm, read, resolve, str, utf8 } from "../src/json.ts"; import { applyQualityFact } from "../src/prepare.ts"; -import type { JObj, JVal } from "../src/json.ts"; +import type { JArr, JObj, JStr, JVal } from "../src/json.ts"; import { instant } from "../src/time.ts"; import { loadVector } from "../src/vector.ts"; import { verify } from "../src/verify.ts"; @@ -84,6 +84,20 @@ test("§4.3: both readers check the typed result and retained replay vectors", ( assert.equal((member(row, "expected") as { v: string }).v, error.code, id); } } + const accepted = member(((member(root, "cases") as { a: JObj[] }).a[0]), "record") as JObj; + for (const mutationValue of (member(root, "mutations") as { a: JVal[] }).a) { + const mutation = mutationValue as JObj; + const id = (member(mutation, "id") as JStr).v; + const record = parse(text(preparationForm(accepted))) as JObj; + const results = member(record, "results") as JObj; + const field = (member(mutation, "field") as JStr).v; + const fields: [string, JVal][] = [["status", member(mutation, "status")!], ["cites", member(mutation, "cites") ?? arr([])]]; + if (member(mutation, "value") !== undefined) fields.push(["value", member(mutation, "value")!]); + results.m.set(field, obj(fields)); + const acquisition = obj([["research", obj([["response", obj([["result", record]])]])]]); + assert.throws(() => readAcquisitions(acquisition, new Map([["research", recipe]])), + (error: unknown) => error instanceof Refusal && error.code === (member(mutation, "expected") as JStr).v, id); + } }); test("§4.3: typed result refusal mutations are caught", () => { @@ -151,6 +165,30 @@ test("§2: semantic declarations change the dialect digest", () => { assert.notEqual(digest(conversion), digest(changed)); }); +test("§4.3 and §11: public verifier replays retained research bytes", () => { + const path = join(VECTORS, "..", "research-replay-vector.json"); + const root = parse(readFileSync(path, "utf8")) as JObj; + const bytes = (name: string) => preparationForm(member(root, name)!); + const inputs: Inputs = { + dialect: bytes("dialect"), case: bytes("case"), acquisitions: bytes("acquisitions"), + profiles: bytes("profiles"), preparation: bytes("preparation"), requireBinding: false, + }; + const result = verify(inputs); + assert.equal(result.kind, "reading"); + assert.deepEqual(Buffer.from(result.recomputed!), Buffer.from(inputs.preparation)); + + const changed = parse(text(inputs.acquisitions)) as JObj; + const research = member(changed, "research") as JObj; + const response = member(research, "response") as JObj; + const record = member(response, "result") as JObj; + const execution = member(record, "execution") as JObj; + const retained = member(execution, "retainedBytes") as JArr; + (retained.a[0] as JObj).m.set("base64", member(root, "tamperedBase64")!); + const tampered = verify({ ...inputs, acquisitions: preparationForm(changed) }); + assert.equal(tampered.kind, "finding"); + assert.equal((member(tampered.report, "finding") as JStr).v, (member(root, "expectedTampered") as JStr).v); +}); + test("§9.2 and §9.3: a skipped source keeps its claim and every declared dependency", () => { const dir = join(VECTORS, "skipped-source-dependency-unavailable"); const inputs = loadVector(dir, true); diff --git a/tools/dialect_verify.py b/tools/dialect_verify.py index 100c400..d136ba4 100644 --- a/tools/dialect_verify.py +++ b/tools/dialect_verify.py @@ -930,7 +930,7 @@ def check_conversion(value: Any, what: str) -> dict[str, Any]: closed(value, {"kind", "decimalSeparator", "thousandsSeparator", "sign", "whitespace"}, set(), what) if value["decimalSeparator"] not in (".", ","): raise malformed(f"{what}: decimalSeparator") - if not isinstance(value["thousandsSeparator"], str) or value["thousandsSeparator"] == value["decimalSeparator"]: + if value["thousandsSeparator"] not in ("", ".", ",") or value["thousandsSeparator"] == value["decimalSeparator"]: raise malformed(f"{what}: thousandsSeparator") if value["sign"] not in ("none", "minus", "plus-minus") or value["whitespace"] not in ("reject", "trim"): raise malformed(f"{what}: sign or whitespace") @@ -1911,7 +1911,7 @@ def check_answers(answers: Any, research: Research, responses: int, what: str) - where = f"{what} field {name}" kind = research.types[name] status = entry.get("status") if isinstance(entry, dict) else None - if status in ("found", "present"): + if status == ("present" if research.result_member == "results" else "found"): closed(entry, {"status", "cites", "value"}, set(), where) check_cites(entry["cites"], responses, 1, where) check_typed(entry["value"], kind, where) @@ -1929,8 +1929,10 @@ def check_answers(answers: Any, research: Research, responses: int, what: str) - first = observations[0]["value"] if all(json_equal(first, observation["value"]) for observation in observations): raise malformed(f"{where}: no two conflicting values differ") - elif isinstance(status, str) and status in ("not-found", "missing", "inaccessible", "stale"): - needs_reason = research.result_member == "results" and status in ("missing", "inaccessible", "stale") + elif isinstance(status, str) and status in ( + ("missing", "inaccessible", "stale") if research.result_member == "results" else ("not-found", "stale") + ): + needs_reason = research.result_member == "results" required = {"status", "cites", "reason"} if needs_reason else {"status", "cites"} closed(entry, required, set(), where) check_cites(entry["cites"], responses, 0, where) @@ -2443,7 +2445,23 @@ def _convert(value: Any, rule: dict[str, Any]) -> tuple[Any, str | None]: if kind == "decimal-from-number": if not isinstance(value, Num): return ABSENT, "not-a-number" - return format(value.value, "f"), None + match = re.fullmatch(r"(-?)([0-9]+)(?:\.([0-9]+))?(?:[eE]([+-]?[0-9]+))?", value.literal) + assert match is not None + sign, whole, fraction, exponent = match.groups() + digits = whole + (fraction or "") + point = len(whole) + int(exponent or "0") + if point <= 0: + expanded_whole, expanded_fraction = "0", "0" * -point + digits + elif point >= len(digits): + expanded_whole, expanded_fraction = digits + "0" * (point - len(digits)), "" + else: + expanded_whole, expanded_fraction = digits[:point], digits[point:] + expanded_whole = expanded_whole.lstrip("0") or "0" + expanded_fraction = expanded_fraction.rstrip("0") + rendered = expanded_whole + ("." + expanded_fraction if expanded_fraction else "") + if rendered == "0": + return "0", None + return ("-" if sign else "") + rendered, None if not isinstance(value, str): return ABSENT, "invalid-encoding" if kind == "trim": @@ -2466,17 +2484,41 @@ def _convert(value: Any, rule: dict[str, Any]) -> tuple[Any, str | None]: if (sign == "+" and rule["sign"] != "plus-minus") or (sign == "-" and rule["sign"] == "none"): return ABSENT, "invalid-encoding" thousands = rule["thousandsSeparator"] - if thousands: - text = text.replace(thousands, "") - text = text.replace(rule["decimalSeparator"], ".") - rendered = ("-" if sign == "-" else "") + text + decimal_parts = text.split(rule["decimalSeparator"]) + if len(decimal_parts) > 2: + return ABSENT, "not-a-number" + integer, fraction = decimal_parts[0], decimal_parts[1] if len(decimal_parts) == 2 else None + ascii_digits = lambda part: bool(part) and part.isascii() and part.isdigit() + if fraction is not None and not ascii_digits(fraction): + return ABSENT, "not-a-number" + groups = integer.split(thousands) if thousands else [integer] + if len(groups) == 1: + if not ascii_digits(integer) or (len(integer) > 1 and integer[0] == "0"): + return ABSENT, "not-a-number" + elif (not 1 <= len(groups[0]) <= 3 or not ascii_digits(groups[0]) or groups[0][0] == "0" + or any(len(group) != 3 or not ascii_digits(group) for group in groups[1:])): + return ABSENT, "not-a-number" + rendered = ("-" if sign == "-" else "") + "".join(groups) + if fraction is not None: + rendered += "." + fraction if DECIMAL_TEXT.fullmatch(rendered) is None: return ABSENT, "not-a-number" return rendered, None if kind == "date-from-text": - forms = {"YYYY-MM-DD": "%Y-%m-%d", "MM/DD/YYYY": "%m/%d/%Y", "DD/MM/YYYY": "%d/%m/%Y"} + patterns = { + "YYYY-MM-DD": r"([0-9]{4})-([0-9]{2})-([0-9]{2})", + "MM/DD/YYYY": r"([0-9]{2})/([0-9]{2})/([0-9]{4})", + "DD/MM/YYYY": r"([0-9]{2})/([0-9]{2})/([0-9]{4})", + } + match = re.fullmatch(patterns[rule["format"]], value) + if match is None: + return ABSENT, "invalid-encoding" + a, b, c = (int(part) for part in match.groups()) + year, month, day = (a, b, c) if rule["format"] == "YYYY-MM-DD" else ( + (c, a, b) if rule["format"] == "MM/DD/YYYY" else (c, b, a) + ) try: - parsed = datetime.datetime.strptime(value, forms[rule["format"]]).date() + parsed = datetime.date(year, month, day) except ValueError: return ABSENT, "invalid-encoding" return parsed.isoformat(), None @@ -2485,7 +2527,7 @@ def _convert(value: Any, rule: dict[str, Any]) -> tuple[Any, str | None]: def _quality_type(value: Any, kind: str) -> bool: if value is None: - return True + return False if kind == "string": return isinstance(value, str) if kind == "decimal": @@ -3060,6 +3102,21 @@ def citation(receipt: dict[str, Any]) -> dict[str, Any]: return {name: receipt[name] for name in ("callIndex", "sessionId", "signature")} +def research_execution_lineage(record: dict[str, Any]) -> dict[str, Any]: + """Commit the resolved settings and decoded retained response bytes without repeating the bytes.""" + execution = record["execution"] + return { + "resolvedSettings": execution["resolvedSettings"], + "retainedBytes": [ + { + "mediaType": item["mediaType"], + "digest": digest_of(base64.b64decode(item["base64"], validate=True)), + } + for item in execution["retainedBytes"] + ], + } + + def read_digest(read: Read) -> str: return digest_of(canon(read.as_written)) @@ -3162,6 +3219,9 @@ def build(dialect: Dialect, outcomes: list[Outcome], verified_at: str) -> Comput entry["receipts"] = list(outcome.citations) if outcome.answer is not None: entry["calculation"] = calculation_lineage(dialect, outcome) + if (outcome.source is not None and outcome.source.research is not None + and outcome.source.research.result_member == "results" and outcome.record is not None): + entry["researchExecution"] = research_execution_lineage(outcome.record) if kind == "fact" and target in outcome.transformations: entry["transformation"] = outcome.transformations[target] lineage.append(entry) From e51da60fde9e887173a6e6d59ff69160d1c544df Mon Sep 17 00:00:00 2001 From: kikashy Date: Thu, 22 Oct 2026 15:50:46 -0400 Subject: [PATCH 4/4] Add the research replay vector named by the fix (#169) Signed-off-by: kikashy --- .../dialect/research-replay-vector.json | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 conformance/dialect/research-replay-vector.json diff --git a/conformance/dialect/research-replay-vector.json b/conformance/dialect/research-replay-vector.json new file mode 100644 index 0000000..b02f0ae --- /dev/null +++ b/conformance/dialect/research-replay-vector.json @@ -0,0 +1,103 @@ +{ + "status": "staged", + "dialect": { + "version": 3, + "case": {"facts": [], "evidence": []}, + "sources": [{ + "name": "research", + "kind": "research", + "profile": "assistant", + "profileDigest": "sha256:4212594e06b8c421c1a8de92ac5a1260b1d0e05cdca027bd65216c20be8b4540", + "webProfiles": {}, + "maxAge": 300, + "question": "Summarize the retained response.", + "constraints": {"synthetic": true}, + "model": "synthetic-research-model-1", + "evidence": ["Use only the retained response."], + "results": [{"id":"summary","type":"string","required":true,"description":"The retained summary.","cardinality":"one"}], + "acquisitionSources": [{"profile":"public-pages","version":"1","purpose":"discovery","scope":{"origins":["https://example.invalid"]},"freshness":86400}], + "capabilities": ["answer"], + "read": {"copy":{"facts":[{"target":"/research/state","source":"/results/summary/status","type":"string","required":true,"convert":{"kind":"trim"}}],"evidence":[]}} + }], + "admits":{"facts":{"/research/state":["generated"]}} + }, + "case": {}, + "acquisitions": { + "research": {"response":{"result":{ + "results":{"summary":{"status":"missing","cites":[],"reason":"The retained response contains no summary."}}, + "responses":[], + "execution":{ + "resolvedSettings":{"model":"synthetic-research-model-1","profile":"assistant","version":"1","settings":{"temperature":"0"}}, + "retainedBytes":[{"mediaType":"application/json","base64":"eyJzdGF0dXMiOiJtaXNzaW5nIn0="}] + } + }}} + }, + "profiles": [{ + "id":"assistant", + "publicKey":"c41a130f46f6427931f1fe90bf0702de9980108fcef8f845722ddab4afda3511", + "class":"generated", + "source":"web-search", + "authority":"stand-in", + "shape":"http", + "adapter":{"name":"adapter-sources","version":"stand-in","digest":"sha256:e05658804f621bd96ba149df689a8410a625573ff9bc1cf2376732e67d3dc061"}, + "endpoint":"https://127.0.0.1:37399/answers", + "research":{"capabilities":["answer","search"],"model":"synthetic-research-model-1"} + }], + "verifiedAt": "2026-10-22T12:00:00Z", + "tamperedBase64": "eyJzdGF0dXMiOiJwcmVzZW50In0=", + "expectedTampered": "preparation-differs", + "preparation": { + "version": 3, + "verifiedAt": "2026-10-22T12:00:00Z", + "mappingDigest": "sha256:def3cc8511ce213d094ce04a4edd0961f0a2f2ed8731f97bc9139b2cefbb4250", + "verification": "verified acquisitions; session sealing and input truth are not established", + "cites": [], + "lineage": [{ + "source": "research", + "kind": "fact", + "target": "/research/state", + "class": "generated", + "generatedInfluence": true, + "present": true, + "status": "resolved", + "reason": "copied", + "basis": ["/results/summary/status"], + "readDigest": "sha256:0e7ce757bc774f0f3bd37c54c82dd5bd2b84e2572bc128e9134f29362a499898", + "candidateFrom": ["/results/summary/status"], + "receipts": [], + "researchExecution": { + "resolvedSettings":{"model":"synthetic-research-model-1","profile":"assistant","version":"1","settings":{"temperature":"0"}}, + "retainedBytes":[{"mediaType":"application/json","digest":"sha256:e0d52d700598d4fcbac5ac5350694f541f89510c7ca61774cad9c1742f031fa5"}] + }, + "transformation": { + "applications":[{"id":"source","source":"/results/summary/status","state":"present","sourceText":"\"missing\"","rule":{"kind":"trim"},"result":"missing"}], + "findings":[] + } + }], + "outcomes": [ + { + "name":"case", + "parameters":{"runAt":"2026-10-22T12:00:00Z"}, + "claim":{"facts":{},"evidenceAvailability":{},"acquisitionStatus":"resolved","reason":"copied","basis":[]}, + "status":"resolved", + "reason":"copied" + }, + { + "name":"research", + "parameters":{"runAt":"2026-10-22T12:00:00Z"}, + "claim":{"facts":{"research":{"state":"missing"}},"evidenceAvailability":{},"acquisitionStatus":"resolved","reason":"copied","basis":["/results/summary/status"]}, + "status":"resolved", + "reason":"copied", + "arguments":{ + "question":"Summarize the retained response.", + "constraints":{"synthetic":true}, + "model":"synthetic-research-model-1", + "evidence":["Use only the retained response."], + "results":[{"id":"summary","type":"string","required":true,"description":"The retained summary.","cardinality":"one"}], + "capabilities":["answer"], + "acquisitionSources":[{"profile":"public-pages","version":"1","purpose":"discovery","scope":{"origins":["https://example.invalid"]},"freshness":86400}] + } + } + ] + } +}