diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e1da1a..d16c013 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. - RFC 0020 limits the later-sibling `limit-exceeded` rule to instances admitted for acquisition; admission failures keep their own codes, with a recorded positive vector and negative twin (#213). - Document ceiling profiles (#232): default `base` and declared `extended`, a shared diff --git a/conformance/dialect/README.md b/conformance/dialect/README.md index d0d7870..2d5f151 100644 --- a/conformance/dialect/README.md +++ b/conformance/dialect/README.md @@ -64,6 +64,20 @@ 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. [`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 Each vector is a directory. Of its files, a preparation verifier reads: diff --git a/conformance/dialect/quality-vectors.json b/conformance/dialect/quality-vectors.json new file mode 100644 index 0000000..264f95f --- /dev/null +++ b/conformance/dialect/quality-vectors.json @@ -0,0 +1,40 @@ +{ + "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":"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-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"}, + {"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":"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}, + {"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-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}] + } + } + ] + } +} diff --git a/conformance/dialect/research-result-vectors.json b/conformance/dialect/research-result-vectors.json new file mode 100644 index 0000000..c240279 --- /dev/null +++ b/conformance/dialect/research-result-vectors.json @@ -0,0 +1,63 @@ +{ + "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" + } + ], + "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 0e648b2..433e0cc 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 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 +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,29 @@ 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 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 +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 +1459,37 @@ 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. + +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 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 +1903,85 @@ 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`, 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 | +| `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. + +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. 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 +`{"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 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`, +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..175d737 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,29 @@ 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 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 +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 +613,37 @@ 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. + +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 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 +1057,85 @@ 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`, 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 | +| `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. + +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. 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 +`{"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 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`, +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..9cb0161 100644 --- a/tests/test_dialect.py +++ b/tests/test_dialect.py @@ -2686,6 +2686,155 @@ 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"]) + 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") + 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)) + + 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: # 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..43224b9 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) || !['', '.', ','].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") { + 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 === (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`); @@ -1288,9 +1429,12 @@ 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 ((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`); } else { malformed(`${w} is not an entry of a known status`); } @@ -1318,7 +1462,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 +1477,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..2bf286d 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,144 @@ 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"); + 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 } { + 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; + 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; + 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 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"; + 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 +222,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 +541,23 @@ 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); +} + +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 @@ -515,11 +670,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 +686,7 @@ export function recompute(d: Dialect, caseDoc: JVal, acquisitions: Map s.name === name)) finding("acquisition-unexpected", `the acquisition ${name} names no source`); @@ -663,6 +821,8 @@ 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 +886,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..d4b24c0 100644 --- a/tools/dialect-verify-ts/test/units.test.ts +++ b/tools/dialect-verify-ts/test/units.test.ts @@ -11,9 +11,10 @@ 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 type { JObj, JVal } 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 { 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"; @@ -22,6 +23,172 @@ 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); + } + } + 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", () => { + 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("§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 50bd914..d136ba4 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 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") + 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 == ("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) @@ -1774,9 +1929,15 @@ 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 ( + ("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) + 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 +1955,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 +1972,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 +1999,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 +2044,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 +2100,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 +2168,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 +2431,192 @@ 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" + 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": + 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"] + 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": + 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.date(year, month, day) + 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 False + 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 +2863,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 +2915,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 +2978,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 +3093,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) @@ -2720,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)) @@ -2822,6 +3219,11 @@ 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) if kind == "fact" and present: write_at(facts_document, target, resolve(claim["facts"], target))