agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
287 lines • 13.3 kB
JavaScript
;
/**
* ontology/define — `defineOntology`: validate a declaration, detach it,
* freeze it, fingerprint it.
*
* Pattern: one pure function over plain JSON plus one pure hash; no I/O,
* no registry (the tool names in `via` are checked where the
* registry exists — `Agent.ts · buildChart`, beside
* `buildToolRegistry` — so `.build()` is what refuses them).
* Role: Map. It decides nothing about what the model sees; it decides
* whether a declaration is well-formed enough to be served as data.
* Emits: N/A.
*
* THE LAWS
* - Every fault is refused by NAME at definition time, never repaired: an
* edge to a node that does not exist, a node held by a source that was
* never declared, an id that is not identifier-safe, a text that is
* empty or longer than a served line may carry. A declaration that
* reaches an agent is one every served line can quote whole.
* - `configured` is copied only when the author wrote it. Absent stays
* absent; the served line then says nothing about configuration
* (`serve.ts`). The library never assumes a source is wired.
* - The result is detached (rebuilt from the input, never aliased) and
* deep-frozen, so a later edit of the caller's objects cannot move what
* `seed` puts on the record.
* - `ontologyHash` fingerprints the FIVE declared fields through the
* receipt's own canonical JSON (`stableJson.ts · stableJson` — keys sorted,
* `undefined` dropped — one owner of the rule) and `sha256Hex`: two
* declarations that differ only in key order hash the same; edge order
* is declaration order and is part of the identity.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ontologyHash = exports.defineOntology = exports.ONTOLOGY_LIMITS = void 0;
const stableJson_js_1 = require("../lib/stableJson.js");
const sha256_js_1 = require("../lib/sha256.js");
/** The bounds a declaration must fit — each one is what keeps a served line one line. */
exports.ONTOLOGY_LIMITS = Object.freeze({
/** Chars per id (`id`, a node key, a source key). */
idChars: 64,
/** Chars per text (`meaning`, `unit`, `coverage`, an alias, a relation, `version`). */
textChars: 512,
nodes: 256,
sources: 64,
edges: 512,
/** Aliases on one node — and, since 9.109.0, on one source. */
aliasesPerNode: 16,
sourcesPerNode: 8,
viaPerSource: 16,
});
/** Identifier-safe: a letter, then letters, digits, `_`, `.` or `-`. */
const ID_RE = /^[A-Za-z][A-Za-z0-9_.-]*$/;
/** The keys each shape accepts — anything else is refused by name, not stripped. */
const ALLOWED_SOURCE_KEYS = ['meaning', 'coverage', 'configured', 'aliases'];
const ALLOWED_NODE_SOURCE_KEYS = ['source', 'via', 'coverage'];
const ALLOWED_NODE_KEYS = ['meaning', 'unit', 'aliases', 'sources'];
const ALLOWED_EDGE_KEYS = ['from', 'to', 'relation', 'meaning'];
function refuse(fault) {
throw new TypeError(`defineOntology: ${fault}`);
}
function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Refuse any key not in `allowed` — an unknown key is a fault named at
* definition time, never silently stripped (a stripped key would make
* `ontologyHash(rawSpec)` disagree with `defineOntology(rawSpec).hash`).
*/
function checkNoUnknownKeys(value, allowed, what) {
for (const key of Object.keys(value)) {
if (!allowed.includes(key))
refuse(`${what} carries unknown key '${key}'.`);
}
}
function checkId(value, what) {
if (typeof value !== 'string' || value.length === 0 || value.length > exports.ONTOLOGY_LIMITS.idChars) {
refuse(`${what} must be a non-empty string of at most ${exports.ONTOLOGY_LIMITS.idChars} characters.`);
}
if (!ID_RE.test(value)) {
refuse(`${what} '${value}' is not identifier-safe (a letter, then letters, digits, '_', '.' or '-').`);
}
return value;
}
function checkText(value, what) {
if (typeof value !== 'string' ||
value.trim().length === 0 ||
value.length > exports.ONTOLOGY_LIMITS.textChars) {
refuse(`${what} must be a non-empty string of at most ${exports.ONTOLOGY_LIMITS.textChars} characters.`);
}
return value;
}
function checkOptionalText(value, what) {
return value === undefined ? undefined : checkText(value, what);
}
function checkNames(value, what, max) {
if (!Array.isArray(value) || value.length > max) {
refuse(`${what} must be an array of at most ${max} strings.`);
}
const names = value.map((entry, i) => checkText(entry, `${what}[${i}]`));
if (new Set(names).size !== names.length)
refuse(`${what} repeats a name.`);
return Object.freeze(names);
}
function checkSource(id, value) {
if (!isRecord(value))
refuse(`sources['${id}'] must be an object.`);
checkNoUnknownKeys(value, ALLOWED_SOURCE_KEYS, `source '${id}'`);
const meaning = checkText(value.meaning, `sources['${id}'].meaning`);
const coverage = checkOptionalText(value.coverage, `sources['${id}'].coverage`);
const configured = value.configured;
if (configured !== undefined && typeof configured !== 'boolean') {
refuse(`sources['${id}'].configured must be a boolean when given.`);
}
const aliases = value.aliases === undefined
? undefined
: checkNames(value.aliases, `sources['${id}'].aliases`, exports.ONTOLOGY_LIMITS.aliasesPerNode);
return Object.freeze({
meaning,
...(coverage !== undefined && { coverage }),
...(configured !== undefined && { configured }),
...(aliases !== undefined && { aliases }),
});
}
function checkNodeSource(nodeId, index, value, sourceIds) {
const what = `nodes['${nodeId}'].sources[${index}]`;
if (!isRecord(value))
refuse(`${what} must be an object.`);
checkNoUnknownKeys(value, ALLOWED_NODE_SOURCE_KEYS, what);
const source = checkId(value.source, `${what}.source`);
if (!sourceIds.has(source)) {
refuse(`node '${nodeId}' is held by source '${source}', which is not declared in sources.`);
}
const via = value.via === undefined
? undefined
: checkNames(value.via, `${what}.via`, exports.ONTOLOGY_LIMITS.viaPerSource);
const coverage = checkOptionalText(value.coverage, `${what}.coverage`);
return Object.freeze({
source,
...(via !== undefined && { via }),
...(coverage !== undefined && { coverage }),
});
}
function checkNode(id, value, sourceIds) {
if (!isRecord(value))
refuse(`nodes['${id}'] must be an object.`);
checkNoUnknownKeys(value, ALLOWED_NODE_KEYS, `node '${id}'`);
const meaning = checkText(value.meaning, `nodes['${id}'].meaning`);
const unit = checkOptionalText(value.unit, `nodes['${id}'].unit`);
const aliases = value.aliases === undefined
? undefined
: checkNames(value.aliases, `nodes['${id}'].aliases`, exports.ONTOLOGY_LIMITS.aliasesPerNode);
let sources;
if (value.sources !== undefined) {
if (!Array.isArray(value.sources) || value.sources.length > exports.ONTOLOGY_LIMITS.sourcesPerNode) {
refuse(`nodes['${id}'].sources must be an array of at most ${exports.ONTOLOGY_LIMITS.sourcesPerNode} entries.`);
}
const held = value.sources.map((entry, i) => checkNodeSource(id, i, entry, sourceIds));
if (new Set(held.map((h) => h.source)).size !== held.length) {
refuse(`node '${id}' names the same source twice.`);
}
sources = Object.freeze(held);
}
return Object.freeze({
meaning,
...(unit !== undefined && { unit }),
...(aliases !== undefined && { aliases }),
...(sources !== undefined && { sources }),
});
}
function checkEdge(index, value, nodeIds) {
const what = `edges[${index}]`;
if (!isRecord(value))
refuse(`${what} must be an object.`);
checkNoUnknownKeys(value, ALLOWED_EDGE_KEYS, what);
const from = checkId(value.from, `${what}.from`);
const to = checkId(value.to, `${what}.to`);
for (const end of [from, to]) {
if (!nodeIds.has(end))
refuse(`edge ${index} names node '${end}', which is not declared in nodes.`);
}
const relation = checkText(value.relation, `${what}.relation`);
const meaning = checkOptionalText(value.meaning, `${what}.meaning`);
return Object.freeze({ from, to, relation, ...(meaning !== undefined && { meaning }) });
}
/**
* Validate a declaration and return it detached, frozen and fingerprinted.
* Throws a `TypeError` naming the first fault found. The registered-tool
* check on `via` happens at `Agent` build, where a registry exists.
*/
function defineOntology(spec) {
if (!isRecord(spec))
refuse('expected a spec object.');
const id = checkId(spec.id, 'id');
const version = checkText(spec.version, 'version');
if (version.length > exports.ONTOLOGY_LIMITS.idChars) {
refuse(`version must be at most ${exports.ONTOLOGY_LIMITS.idChars} characters.`);
}
if (!isRecord(spec.sources))
refuse('sources must be a record of source declarations.');
if (!isRecord(spec.nodes))
refuse('nodes must be a record of node declarations.');
const sourceEntries = Object.entries(spec.sources);
if (sourceEntries.length > exports.ONTOLOGY_LIMITS.sources) {
refuse(`at most ${exports.ONTOLOGY_LIMITS.sources} sources may be declared.`);
}
const nodeEntries = Object.entries(spec.nodes);
if (nodeEntries.length === 0 || nodeEntries.length > exports.ONTOLOGY_LIMITS.nodes) {
refuse(`between 1 and ${exports.ONTOLOGY_LIMITS.nodes} nodes must be declared.`);
}
const sources = {};
for (const [sourceId, value] of sourceEntries) {
sources[checkId(sourceId, 'a source id')] = checkSource(sourceId, value);
}
const sourceIds = new Set(Object.keys(sources));
const nodes = {};
for (const [nodeId, value] of nodeEntries) {
const checkedNodeId = checkId(nodeId, 'a node id');
if (sourceIds.has(checkedNodeId)) {
refuse(`node id '${checkedNodeId}' collides with source id '${checkedNodeId}'.`);
}
nodes[checkedNodeId] = checkNode(checkedNodeId, value, sourceIds);
}
const nodeIds = new Set(Object.keys(nodes));
for (const [nodeId, node] of Object.entries(nodes)) {
for (const alias of node.aliases ?? []) {
if (alias !== nodeId && nodeIds.has(alias)) {
refuse(`node '${nodeId}' alias '${alias}' collides with node id '${alias}'.`);
}
}
}
// A source's alias (9.109.0) must not be another declared id of either
// kind: an answer naming it would then name two things.
for (const [sourceId, source] of Object.entries(sources)) {
for (const alias of source.aliases ?? []) {
if (alias !== sourceId && (sourceIds.has(alias) || nodeIds.has(alias))) {
refuse(`source '${sourceId}' alias '${alias}' collides with a declared id '${alias}'.`);
}
}
}
if (spec.edges !== undefined && !Array.isArray(spec.edges)) {
refuse('edges must be an array when given.');
}
const rawEdges = spec.edges ?? [];
if (rawEdges.length > exports.ONTOLOGY_LIMITS.edges) {
refuse(`at most ${exports.ONTOLOGY_LIMITS.edges} edges may be declared.`);
}
const edges = rawEdges.map((value, i) => checkEdge(i, value, nodeIds));
const seen = new Set();
for (const edge of edges) {
const key = `${edge.from}${edge.relation}${edge.to}`;
if (seen.has(key))
refuse(`edge '${edge.from} —${edge.relation}→ ${edge.to}' is declared twice.`);
seen.add(key);
}
const declared = {
id,
version,
nodes: Object.freeze(nodes),
sources: Object.freeze(sources),
edges: Object.freeze(edges),
};
return Object.freeze({ ...declared, hash: ontologyHash(declared) });
}
exports.defineOntology = defineOntology;
/**
* The declaration's fingerprint: SHA-256 (64 hex) over the canonical JSON
* of `{ id, version, nodes, sources, edges }` — the receipt's `stableJson`,
* so key order never moves it. `hash` on an `Ontology` is never an input.
* Defined over a VALIDATED spec: called on a raw spec that `defineOntology`
* would refuse (an unknown key, a bad shape), the hash still includes
* whatever that raw spec carries, so `ontologyHash(raw) === defineOntology(raw).hash`
* holds only once the spec is one `defineOntology` accepts.
*/
function ontologyHash(ontology) {
const canonical = (0, stableJson_js_1.stableJson)({
id: ontology.id,
version: ontology.version,
nodes: ontology.nodes,
sources: ontology.sources,
edges: ontology.edges ?? [],
});
// `stableJson` returns undefined only for a value JSON cannot express; a
// validated spec is plain strings, records, arrays and booleans, so this
// is defence for a hand-built object, not the common path.
return (0, sha256_js_1.sha256Hex)(canonical ?? '');
}
exports.ontologyHash = ontologyHash;
//# sourceMappingURL=define.js.map