UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

748 lines • 37.4 kB
/** * semantics/envelope — minting, recognizing and projecting the semantic * tool-result envelope (9.53.0). * * Pattern: minted by a helper, RECOGNIZED by the framework (the `absent()` / * tool-effects precedent). A return shape the framework does not * understand is a convention, and a convention cannot ride the * record, feed a UI, or be refused by a build gate. * Role: lib/ layer, pure. The dispatch loop calls `readSemantics` at the * execute boundary; `describedResult()` is what a tool author writes; * `checkSemantics` (check.ts) judges the same shapes offline. * Emits: N/A (the caller emits `agentfootprint.tools.semantics_declared`). * * ## Two views of one envelope — the design decision, stated * * The MODEL reads a compact, rendering-free projection * ({@link semanticsForModel}): the data (`series`/`facts`/`edges`), the * caveats that must travel with it (`grain`, `provenance`), the composed * `not_covered` prose, a non-null `clarify`, and the static note. Dropped * from the model's view: the `af_semantics` marker (machine-only), `render` * (a UI hint — the tool never renders, and a model parroting rendering * directives is noise), the three-list `coverage` detail (it rides the * coverage channel and the record; the model reads the composed * `not_covered` lines instead), and a `clarify: null` (a stated non-question * is a fact for the record, not something the model acts on). * * The RECORD gets everything: the full envelope — render, coverage, * marker and all — lands on the `tools.semantics_declared` event BEFORE the * result ceiling is measured, so grain and provenance survive to recordings * and UIs even when the content itself is refused as oversized. The * `coverage` field is additionally declared through the SAME channel the * `coverage()` primitive uses (`tools.coverage_declared`, tracked state, the * final-answer limits block) — absorbed, never duplicated. * * ## One rule set for authoring and recognition * * The mint refuses a declaration this vocabulary cannot honor at the * CALL SITE (the `absent()` law) — so a minted envelope is honest by * construction: series carry their grain, data carries its provenance, * counter-looking aggregations state `is_counter`. `semanticIssues()` judges * the RENDERED shape — the same rules over a value somebody may have built * by hand — and is what recognition and the `check:semantics` gate both * stand on. Recognition is STRICT (the zero-cost guarantee): a marker- * bearing value with any issue is NOT recognized — it keeps its bytes on * the data path (dev-warned, and named field-by-field by the gate), because * this library does not half-apply a shape it cannot fully honor. * * ## One authoring door, one unchanged wire * * `describedResult()` declares camelCase names, then `mintSemantics` applies * the same rule set used to recognize stored snake_case envelopes. Validation * and record reading keep one owner; old wire records never need an adapter. */ import { normalizeCoverageList } from './coverage/items.js'; import { copyPeriod, mintPeriod, noteWithClause, periodProblem, readPeriod, servedPeriod, } from './coverage/period.js'; import { mintPaging, mintResolved, pagingProblem, readPaging, readResolved, resolvedProblem, } from './coverage/search/resultFacts.js'; import { refusal, refuseUnknownKeys } from './coverage/refusal.js'; import { COUNTER_AGGREGATION_WORDS, SEMANTICS_MARKER, SEMANTICS_NOTE, } from './types.js'; const isNonEmptyString = (v) => typeof v === 'string' && v.trim().length > 0; const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v); /** Whole-token match against {@link COUNTER_AGGREGATION_WORDS}, singular or * plural, case-insensitive — 'sum' and 'Counts' look like counters, * 'summary' does not. */ export function isCounterLookingAggregation(aggregation) { const tokens = aggregation.toLowerCase().split(/[^a-z]+/); return tokens.some((t) => COUNTER_AGGREGATION_WORDS.includes(t) || (t.endsWith('s') && COUNTER_AGGREGATION_WORDS.includes(t.slice(0, -1)))); } const ENVELOPE_KEYS = new Set([ SEMANTICS_MARKER, 'series', 'facts', 'edges', 'grain', 'provenance', // Honesty step 7b: what the read behind the data covered in time. An OLDER // reader refuses a whole envelope that carries it (this set is its law), so // the changelog names the floor. 'period', // A search tool's two result facts (`coverage/search/resultFacts.ts`). An // OLDER reader refuses a whole envelope that carries either, as for `period`. 'resolved', 'paging', 'coverage', 'not_covered', 'clarify', 'render', 'note', ]); const GRAIN_KEYS = new Set(['interval', 'aggregation', 'is_counter', 'collapsed']); const PROVENANCE_KEYS = new Set(['measured_at', 'age_seconds', 'source', 'source_export_date']); const COVERAGE_KEYS = new Set(['checked', 'not_checked', 'cannot_cover']); /** * The keys a {@link SemanticClarify} has — spelled the same in declarations * and recorded envelopes — tied to the type in BOTH directions. */ export const CLARIFY_DECLARATION_KEYS = Object.keys({ question: true, candidates: true, }); const CLARIFY_KEYS = new Set(CLARIFY_DECLARATION_KEYS); const RENDER_KEYS = new Set(['default', 'columns', 'sort', 'filter_note', 'chart_hint']); /** The wire's own spelling — what `semanticIssues`, recognition, the gate * speak. */ export const WIRE_SPELLING = { is_counter: 'is_counter', measured_at: 'measured_at', age_seconds: 'age_seconds', source_export_date: 'source_export_date', filter_note: 'filter_note', chart_hint: 'chart_hint', }; /** Compose the `not_covered` prose lines FROM coverage — the one derivation, * used by the mint and by the drift check, so the two can never disagree. */ export function composeNotCovered(coverage) { const items = [...(coverage.not_checked ?? []), ...(coverage.cannot_cover ?? [])]; return items.map((i) => (i.why !== undefined ? `${i.what} — ${i.why}` : i.what)); } function malformed(field, message) { return { code: 'malformed-semantics', field, message }; } /** * The fault for an EMPTY data list — `series: []`, `facts: []`, `edges: []` — * which names the one door for "nothing matched": `absent()`. * * The rule itself is unchanged (a data list is never empty, so "nothing * matched" has exactly one door). What changed is the advice: it used to say * "omit the field to say nothing", and following it on the found branch's * only data field walked straight into the next refusal ("this result * declares nothing"). And the fault is data-dependent — a tool with no empty * branch passes every test that has rows and refuses on its first empty read * in production, where the MODEL reads this text in place of the data — so it * says the branch to write, in the author's own helper names. One core, so * the declaration door and the `check:semantics` gate say the same. */ function emptyDataList(field) { return malformed(field, `\`${field}\` is empty — if nothing matched, return absent({ what, checked }) instead.`); } /** * Detach one declared data list (`series`, `facts`, `edges`) for the candidate * envelope. A list — or any other iterable a plain-JavaScript author handed * over, which always minted — is copied into a fresh array. A value that is * NOT a list passes through untouched so the rule set names it for what it is: * a spread would have thrown a `TypeError` on a plain object or a number (a * crash that does not read as a refusal, in the one place the model reads * it), and turned a string into its characters, refused as a malformed ROW. */ function copyDataList(value) { if (typeof value === 'string' || typeof value !== 'object' || value === null) return value; const iterate = value[Symbol.iterator]; return typeof iterate === 'function' ? [...value] : value; } /** Detach one clarify declaration for the candidate envelope. A non-object * passes through untouched so the validator can name it. */ function copyClarify(clarify) { if (!isPlainObject(clarify)) return clarify; return { question: clarify.question, candidates: Array.isArray(clarify.candidates) ? [...clarify.candidates] : clarify.candidates ?? [], }; } function checkItemList(issues, field, list, requireWhy) { if (list === undefined) return []; if (!Array.isArray(list)) { issues.push(malformed(field, `\`${field}\` must be an array of { what, why? } items.`)); return []; } const items = []; list.forEach((raw, i) => { if (!isPlainObject(raw) || !isNonEmptyString(raw.what)) { issues.push(malformed(`${field}[${i}]`, `\`${field}[${i}]\` names no ground — each item is { what, why? }.`)); return; } if (raw.why !== undefined && !isNonEmptyString(raw.why)) { issues.push(malformed(`${field}[${i}].why`, `\`${field}[${i}]\` has a \`why\` that says nothing.`)); return; } if (requireWhy && raw.why === undefined) { issues.push(malformed(`${field}[${i}].why`, `\`${field}[${i}]\` ('${raw.what.trim()}') has no \`why\` — a permanent blind spot is a ` + `claim about capability, and a claim with no reason cannot be acted on or disproved.`)); return; } items.push({ what: raw.what, ...(raw.why !== undefined && { why: raw.why }), }); }); return items; } /** * THE provenance rule — one present `provenance` object, in the wire's * spelling, judged; every fault, each naming its field the way `s` spells it. * `issuesIn` asks it for a described result, and `coverage/absent.ts` asks it * for an absence's `provenance` (honesty step 7b), so the two doors cannot * disagree about what a well-formed source and time is: `measured_at` and * `source` required, `age_seconds` a finite number ≥ 0, `source_export_date` * a non-empty string, no other key. */ export function provenanceIssues(provenance, s) { const measuredAt = s.measured_at; const ageSeconds = s.age_seconds; const sourceExportDate = s.source_export_date; if (!isPlainObject(provenance)) { return [ malformed('provenance', `\`provenance\` must be an object ({ ${measuredAt}, source, ${ageSeconds}?, ${sourceExportDate}? }).`), ]; } const issues = []; for (const key of Object.keys(provenance)) { if (!PROVENANCE_KEYS.has(key)) issues.push(malformed(`provenance.${key}`, `\`provenance.${key}\` is not a provenance field.`)); } if (!isNonEmptyString(provenance.measured_at)) { issues.push({ code: 'data-without-provenance', field: `provenance.${measuredAt}`, message: `\`provenance.${measuredAt}\` must say when the WORLD was measured — a tool reading a ` + 'nightly export and answering in 4ms is serving yesterday, and only this field says so.', }); } if (!isNonEmptyString(provenance.source)) { issues.push({ code: 'data-without-provenance', field: 'provenance.source', message: '`provenance.source` must name the system of record the values were read from.', }); } if (provenance.age_seconds !== undefined && (typeof provenance.age_seconds !== 'number' || !Number.isFinite(provenance.age_seconds) || provenance.age_seconds < 0)) { issues.push(malformed(`provenance.${ageSeconds}`, `\`provenance.${ageSeconds}\` must be a finite number ≥ 0 or omitted.`)); } if (provenance.source_export_date !== undefined && !isNonEmptyString(provenance.source_export_date)) { issues.push(malformed(`provenance.${sourceExportDate}`, `\`provenance.${sourceExportDate}\` must be a non-empty string or omitted.`)); } return issues; } /** * Judge one RENDERED envelope shape against the whole rule set. Empty = a * well-formed envelope this library can honor. Non-empty = the faults, each * naming its field. * * Called with values that carry the marker; on anything else it reports the * missing marker rather than guessing. */ export function semanticIssues(value) { return issuesIn(value, WIRE_SPELLING); } /** * The rule set itself, over the wire-spelled `value`, naming each field the * way `s` spells it. Recognition and the gate pass the wire's * own spelling; `describedResult()` passes camelCase, so its author reads * `grain.isCounter` for the field they wrote. Only the six {@link * SpelledField}s differ, and only in the words — never in what is judged. */ function issuesIn(value, s) { if (!isPlainObject(value)) { return [malformed(SEMANTICS_MARKER, 'a semantic envelope is a plain object.')]; } if (value[SEMANTICS_MARKER] !== true) { return [ malformed(SEMANTICS_MARKER, `\`${SEMANTICS_MARKER}\` must be exactly \`true\` — the marker is the vocabulary.`), ]; } const issues = []; for (const key of Object.keys(value)) { if (!ENVELOPE_KEYS.has(key)) { issues.push(malformed(key, `this result carries '${key}', which is not a field this vocabulary has. The ` + `fields are: series, facts, edges, grain, provenance, period, resolved, paging, ` + `coverage, clarify, render (not_covered and note are derived).`)); } } // ── series ── const series = value.series; if (series !== undefined) { if (Array.isArray(series) && series.length === 0) { issues.push(emptyDataList('series')); } else if (!Array.isArray(series)) { issues.push(malformed('series', '`series` must be a non-empty array of { t, entity, metric, value } points — omit the field to say nothing.')); } else { series.forEach((p, i) => { if (!isPlainObject(p)) { issues.push(malformed(`series[${i}]`, `\`series[${i}]\` is not a point object.`)); return; } if (typeof p.t !== 'string' && typeof p.t !== 'number') { issues.push(malformed(`series[${i}].t`, `\`series[${i}].t\` must be the measurement time (an ISO string or an epoch number).`)); } if (!isNonEmptyString(p.entity)) { issues.push(malformed(`series[${i}].entity`, `\`series[${i}].entity\` must name what was measured.`)); } if (!isNonEmptyString(p.metric)) { issues.push(malformed(`series[${i}].metric`, `\`series[${i}].metric\` must name the measurement.`)); } if (!('value' in p)) { issues.push(malformed(`series[${i}].value`, `\`series[${i}]\` has no \`value\` — a point with no reading is not a point.`)); } }); } } // ── facts ── const facts = value.facts; if (facts !== undefined) { if (Array.isArray(facts) && facts.length === 0) { issues.push(emptyDataList('facts')); } else if (!Array.isArray(facts)) { issues.push(malformed('facts', '`facts` must be a non-empty array of rows — omit the field to say nothing.')); } else { facts.forEach((f, i) => { if (!isPlainObject(f) || !isNonEmptyString(f.entity)) { issues.push(malformed(`facts[${i}].entity`, `\`facts[${i}]\` must say WHAT it is about — every row needs a non-empty \`entity\`.`)); } }); } } // ── edges ── const edges = value.edges; if (edges !== undefined) { if (Array.isArray(edges) && edges.length === 0) { issues.push(emptyDataList('edges')); } else if (!Array.isArray(edges)) { issues.push(malformed('edges', '`edges` must be a non-empty array of { from, to, kind } — omit the field to say nothing.')); } else { edges.forEach((e, i) => { if (!isPlainObject(e) || !isNonEmptyString(e.from) || !isNonEmptyString(e.to) || !isNonEmptyString(e.kind)) { issues.push(malformed(`edges[${i}]`, `\`edges[${i}]\` must carry non-empty \`from\`, \`to\` and \`kind\`.`)); } }); } } // ── grain ── const grain = value.grain; const isCounter = s.is_counter; if (grain !== undefined) { if (!isPlainObject(grain)) { issues.push(malformed('grain', `\`grain\` must be an object ({ interval?, aggregation?, ${isCounter}?, collapsed? }).`)); } else { for (const key of Object.keys(grain)) { if (!GRAIN_KEYS.has(key)) issues.push(malformed(`grain.${key}`, `\`grain.${key}\` is not a grain field.`)); } let says = false; for (const key of ['interval', 'aggregation', 'collapsed']) { if (grain[key] !== undefined) { if (!isNonEmptyString(grain[key])) { issues.push(malformed(`grain.${key}`, `\`grain.${key}\` must be a non-empty string or omitted.`)); } else says = true; } } if (grain.is_counter !== undefined) { if (typeof grain.is_counter !== 'boolean') { issues.push(malformed(`grain.${isCounter}`, `\`grain.${isCounter}\` must be a boolean — "stated" means true or false, never prose.`)); } else says = true; } if (!says && issues.every((i) => !i.field.startsWith('grain'))) { issues.push(malformed('grain', `\`grain\` says nothing — state at least one of interval, aggregation, ${isCounter}, collapsed, or omit the field.`)); } if (isNonEmptyString(grain.aggregation) && isCounterLookingAggregation(grain.aggregation) && typeof grain.is_counter !== 'boolean') { issues.push({ code: 'counter-aggregation-unstated', field: `grain.${isCounter}`, message: `grain.aggregation is '${grain.aggregation.trim()}', which is counter-looking, and ` + `\`${isCounter}\` is not stated. Summing counters double-counts, and a reader cannot ` + `tell a counter from a gauge by looking at a number — state \`${isCounter}: true\` or ` + `\`${isCounter}: false\`.`, }); } } } // ── provenance ── (the ONE rule, `provenanceIssues`, which `absent()` asks too) const provenance = value.provenance; const hasData = series !== undefined || facts !== undefined; if (provenance !== undefined) { issues.push(...provenanceIssues(provenance, s)); } else if (hasData) { issues.push({ code: 'data-without-provenance', field: 'provenance', message: `this result carries series/facts with no \`provenance\` — \`provenance.${s.measured_at}\` and ` + '`provenance.source` are required whenever the envelope carries data: a number with ' + 'no age and no source cannot be trusted or audited.', }); } // ── period ── (honesty step 7b — the ONE rule, `coverage/period.ts` · `periodProblem`). // Judged in the WIRE's spelling whatever the door: the candidate a mint judges // carries a period `mintPeriod` already refused in its author's words, so a // fault here is always one read off an envelope minted elsewhere. if (value.period !== undefined) { const problem = periodProblem(value.period, 'wire'); if (problem !== undefined) issues.push(malformed(problem.field, problem.message)); } // ── resolved / paging ── (the ONE rule set `absent()` mints by). if (value.resolved !== undefined) { const problem = resolvedProblem(value.resolved); if (problem !== undefined) issues.push(malformed('resolved', problem)); } if (value.paging !== undefined) { const problem = pagingProblem(value.paging); if (problem !== undefined) issues.push(malformed('paging', problem)); } // ── series ⇒ grain ── if (series !== undefined && grain === undefined) { issues.push({ code: 'series-without-grain', field: 'grain', message: 'this result carries `series` with no `grain` — a time-series with no stated ' + 'interval/aggregation reads as whatever the reader assumes, which is how counters get ' + 'summed and windows get compared across different intervals.', }); } // ── coverage (rendered) + not_covered derivation ── const coverage = value.coverage; let renderedCoverage; if (coverage !== undefined) { if (!isPlainObject(coverage)) { issues.push(malformed('coverage', '`coverage` must be an object of { checked?, not_checked?, cannot_cover? } item lists.')); } else { for (const key of Object.keys(coverage)) { if (!COVERAGE_KEYS.has(key)) { issues.push(malformed(`coverage.${key}`, `\`coverage.${key}\` is not a coverage list — the three are checked, not_checked, cannot_cover.`)); } } const checked = checkItemList(issues, 'coverage.checked', coverage.checked, false); const notChecked = checkItemList(issues, 'coverage.not_checked', coverage.not_checked, false); const cannotCover = checkItemList(issues, 'coverage.cannot_cover', coverage.cannot_cover, true); if (checked.length + notChecked.length + cannotCover.length === 0) { issues.push(malformed('coverage', '`coverage` names no ground at all — declare at least one item, or omit the field.')); } else { renderedCoverage = { ...(checked.length > 0 && { checked }), ...(notChecked.length > 0 && { not_checked: notChecked }), ...(cannotCover.length > 0 && { cannot_cover: cannotCover }), }; } } } const notCovered = value.not_covered; if (notCovered !== undefined) { if (coverage === undefined) { issues.push(malformed('not_covered', '`not_covered` is DERIVED from `coverage` (not checked + cannot cover) — declare `coverage` instead of writing the prose by hand, so the two cannot disagree.')); } else if (!Array.isArray(notCovered) || !notCovered.every(isNonEmptyString)) { issues.push(malformed('not_covered', '`not_covered` must be an array of non-empty strings.')); } else if (renderedCoverage !== undefined) { const derived = composeNotCovered(renderedCoverage); const same = derived.length === notCovered.length && derived.every((l, i) => l === notCovered[i]); if (!same) { issues.push(malformed('not_covered', '`not_covered` disagrees with what `coverage` derives — it is a derived field; drop it (the mint composes it) or fix the coverage lists.')); } } } // ── clarify ── const clarify = value.clarify; if (clarify !== undefined && clarify !== null) { if (!isPlainObject(clarify)) { issues.push(malformed('clarify', '`clarify` must be { question, candidates } or null (null states "ambiguity was considered; there is none").')); } else { for (const key of Object.keys(clarify)) { if (!CLARIFY_KEYS.has(key)) issues.push(malformed(`clarify.${key}`, `\`clarify.${key}\` is not a clarify field.`)); } if (!isNonEmptyString(clarify.question)) { issues.push(malformed('clarify.question', '`clarify.question` must ask something.')); } if (!Array.isArray(clarify.candidates)) { issues.push(malformed('clarify.candidates', '`clarify.candidates` must be an array (empty is fine — an open question is still a question).')); } } } // ── render ── const render = value.render; if (render !== undefined) { if (!isPlainObject(render)) { issues.push(malformed('render', `\`render\` must be an object ({ default, columns?, sort?, ${s.filter_note}?, ${s.chart_hint}? }).`)); } else { for (const key of Object.keys(render)) { if (!RENDER_KEYS.has(key)) issues.push(malformed(`render.${key}`, `\`render.${key}\` is not a render hint.`)); } if (!isNonEmptyString(render.default)) { issues.push(malformed('render.default', "`render.default` must name the default presentation ('table', 'chart', 'prose', …).")); } if (render.columns !== undefined && (!Array.isArray(render.columns) || render.columns.length === 0 || !render.columns.every(isNonEmptyString))) { issues.push(malformed('render.columns', '`render.columns` must be a non-empty array of column names or omitted.')); } for (const [key, spelled] of [ ['sort', 'sort'], ['filter_note', s.filter_note], ['chart_hint', s.chart_hint], ]) { if (render[key] !== undefined && !isNonEmptyString(render[key])) { issues.push(malformed(`render.${spelled}`, `\`render.${spelled}\` must be a non-empty string or omitted.`)); } } } } // ── note ── if (value.note !== undefined && typeof value.note !== 'string') { issues.push(malformed('note', "`note` is the library's static sentence — a string, or omitted.")); } // ── the envelope must declare SOMETHING ── if (series === undefined && facts === undefined && edges === undefined && (clarify === undefined || clarify === null)) { issues.push(malformed(SEMANTICS_MARKER, 'this result declares nothing — an envelope needs data (series, facts or edges) or a ' + 'question (clarify). Caveats with nothing to caveat are not a result.')); } return issues; } /** * The declared `coverage`, normalized by the SAME validator the * `coverage()`/`absent()` primitives use (one validator, three doors), then * respelled snake_case for the rendered envelope. `undefined` when none was * declared. */ function mintedCoverage(declared) { if (declared === undefined) return undefined; if (!isPlainObject(declared)) { throw refusal(`\`coverage\` must be a { checked?, notChecked?, cannotCover? } declaration.`); } const cov = declared; const checked = normalizeCoverageList('checked', cov.checked, false); const notChecked = normalizeCoverageList('notChecked', cov.notChecked, false); const cannotCover = normalizeCoverageList('cannotCover', cov.cannotCover, true); if (checked.length + notChecked.length + cannotCover.length === 0) { throw refusal(`\`coverage\` names no ground at all — declare at least one item across ` + `checked/notChecked/cannotCover, or omit the field (absent means "not declared", ` + `never "nothing there").`); } return { ...(checked.length > 0 && { checked }), ...(notChecked.length > 0 && { not_checked: notChecked }), ...(cannotCover.length > 0 && { cannot_cover: cannotCover }), }; } /** * The ONE mint behind `describedResult()`. Refuses (throws, at the call site — the `absent()` law) any * declaration this vocabulary cannot honor, in the door's own words; returns * the rendered envelope otherwise. * * In order: the declaration must be an object; a hand-written `not_covered` * is refused as derived; every key the declaration and its objects carry must * be one the door reads (a casing slip names the spelling meant); coverage is * normalized; the candidate is built in the WIRE spelling; and the whole rule * set judges it, naming each field as the door's author spelled it. */ export function mintSemantics(decl, door) { // Deliberately not the isPlainObject guard: its predicate would REPLACE // the declared field types with an index signature for the rest of the // function (each declaration type is assignable to it, so it narrows). if (typeof decl !== 'object' || decl === null || Array.isArray(decl)) { // The accepted fields come from the authoring vocabulary itself. const fields = door.declarationKeys.map((key) => `${key}?`).join(', '); throw refusal(`${door.name}() takes a declaration — { ${fields} } with at least one of ` + `series/facts/edges/clarify.`); } const derived = door.derivedKeys.find((key) => Object.prototype.hasOwnProperty.call(decl, key)); if (derived !== undefined) { throw refusal(`\`${derived}\` is derived, never declared — declare \`coverage\` ` + `({ notChecked, cannotCover }) and the prose list is composed from it, so the two ` + `can never disagree.`); } refuseUnknownKeys(decl, door.declarationKeys); for (const [field, known] of door.objectKeys) { const nested = decl[field]; if (isPlainObject(nested)) refuseUnknownKeys(nested, known, field); } const coverage = mintedCoverage(decl.coverage); const notCovered = coverage !== undefined ? composeNotCovered(coverage) : []; // Honesty step 7b: a top-level `period`, minted by the ONE period rule set // (refused in the author's own camelCase words). const period = 'period' in decl ? mintPeriod(decl.period) : undefined; // A search tool's result facts, by the rule set `absent()` mints with. const resolved = mintResolved(decl.resolved); const paging = mintPaging(decl.paging); const candidate = { [SEMANTICS_MARKER]: true, ...(decl.series !== undefined && { series: copyDataList(decl.series) }), ...(decl.facts !== undefined && { facts: copyDataList(decl.facts) }), ...(decl.edges !== undefined && { edges: copyDataList(decl.edges) }), ...(decl.grain !== undefined && { grain: door.toWire('grain', decl.grain) }), ...(decl.provenance !== undefined && { provenance: door.toWire('provenance', decl.provenance), }), ...(period !== undefined && { period }), ...(resolved !== undefined && { resolved }), ...(paging !== undefined && { paging }), ...(coverage !== undefined && { coverage }), ...(notCovered.length > 0 && { not_covered: notCovered }), ...('clarify' in decl && decl.clarify !== undefined && { clarify: decl.clarify === null ? null : copyClarify(decl.clarify), }), ...(decl.render !== undefined && { render: door.toWire('render', decl.render) }), note: SEMANTICS_NOTE, }; // One rule set, one implementation: the mint judges its own candidate with // the exact validator recognition and the gate use, and refuses the first // fault at the call site — in the words the door's author wrote. const issues = issuesIn(candidate, door.spelling); if (issues.length > 0) { const first = issues[0]; throw refusal(`${first.message} (field: ${first.field})`); } return candidate; } /** * Recognize (or decline to recognize) a value as a semantic envelope — * STRICT, and the strictness is the zero-cost guarantee. Only a plain object * whose `af_semantics` is exactly `true` AND that passes the whole rule set * qualifies; every other value any tool has ever returned takes the path it * always took, byte for byte. * * `undefined` means "not an envelope this library can honor" — a marker- * bearing value with faults stays DATA (never half-applied); the dispatch * loop dev-warns it and `check:semantics` names every fault. */ export function readSemantics(value) { if (!isPlainObject(value) || value[SEMANTICS_MARKER] !== true) return undefined; if (semanticIssues(value).length > 0) return undefined; return value; } /** * Name what is wrong with a value that CARRIES the marker but was not * recognized. `undefined` for values without the marker (they are data, not * near-misses) and for well-formed envelopes. Diagnosis only — never changes * what any value does. */ export function explainSemantics(value) { if (!isPlainObject(value) || value[SEMANTICS_MARKER] !== true) return undefined; const issues = semanticIssues(value); return issues.length > 0 ? issues : undefined; } // LENS · tool-result · persistent-history // reads: the declared ToolSemantics envelope only — a compact projection that may not overclaim coverage // law: may omit, never deny; every clause anchored to the call it was composed on. /** * The MODEL's view of one recognized envelope — compact and rendering-free. * * Keeps: the data (`series`/`facts`/`edges`), the caveats that must travel * with it (`grain`, `provenance`), the composed `not_covered` prose, a * non-null `clarify`, and the static note. Drops: the marker, `render` * (UI hint), the three-list `coverage` detail (rides the coverage channel * and the record), and a `clarify: null`. Shallow-copied so the history * entry is not the object the tool still holds. * * The period is served as the tool declared it — plus, when the store did not * hold all of the time the read asked about, the verdict word inside it and * that word's one clause after the note (`coverage/period.ts` · * `servedPeriod`; honesty step 7b, bench round 1). A `covered` period and an * envelope with none are served byte for byte as before. */ export function semanticsForModel(sem) { const served = servedPeriod(sem.period); const note = typeof sem.note === 'string' ? sem.note : SEMANTICS_NOTE; return { ...(sem.series !== undefined && { series: sem.series.map((p) => ({ ...p })) }), ...(sem.facts !== undefined && { facts: sem.facts.map((f) => ({ ...f })) }), ...(sem.edges !== undefined && { edges: sem.edges.map((e) => ({ ...e })) }), ...(sem.grain !== undefined && { grain: { ...sem.grain } }), ...(sem.provenance !== undefined && { provenance: { ...sem.provenance } }), // Served as the tool declared it (honesty step 7b), with the verdict word // when the store did not hold all of it. ...(sem.period !== undefined && { period: served?.period ?? periodOnWireCopy(sem.period), }), // A search tool's result facts — its own claims, served as declared. ...(sem.resolved !== undefined && { resolved: readResolved(sem.resolved) }), ...(sem.paging !== undefined && { paging: readPaging(sem.paging) }), ...(sem.not_covered !== undefined && sem.not_covered.length > 0 && { not_covered: [...sem.not_covered] }), ...(sem.clarify !== undefined && sem.clarify !== null && { clarify: { question: sem.clarify.question, candidates: [...sem.clarify.candidates] }, }), note: served === undefined ? note : noteWithClause(note, served.clause), }; } /** A wire period as detached plain data — the projection's copy. */ function periodOnWireCopy(period) { return { queried: { from: period.queried.from, to: period.queried.to }, held: period.held === 'unknown' ? 'unknown' : { from: period.held.from, to: period.held.to }, ...(period.read_at !== undefined && { read_at: period.read_at }), }; } /** * The period a RECOGNIZED envelope declared, in the record's camelCase form * (honesty step 7b) — `undefined` when it declared none. Recognition already * held the period to the rule set (a fault keeps the whole envelope data), so * this is a read, never a judgement. */ export function periodOfSemantics(sem) { const read = readPeriod(sem.period); return read.period === undefined ? undefined : copyPeriod(read.period); } /** The envelope's coverage in the normalized three-list shape the coverage * machinery reads — how `readCoverageResult` absorbs a semantic envelope's * boundary into the one coverage channel. */ export function coverageOfSemantics(sem) { return { checked: sem.coverage?.checked ?? [], notChecked: sem.coverage?.not_checked ?? [], cannotCover: sem.coverage?.cannot_cover ?? [], }; } //# sourceMappingURL=envelope.js.map