@polycode-projects/the-mechanical-code-talker 1.4.1 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/ROADMAP.md +76 -11
- package/corpus/README.md +23 -22
- package/corpus/seon/README.md +7 -6
- package/corpus/seon/concepts.jsonl +119 -0
- package/corpus/tier2/general.jsonl +49 -0
- package/corpus/tier2/generate.mjs +68 -0
- package/corpus/tier2/manifest.json +14 -0
- package/data/templates/constructions/agent-noun-relations.toml +98 -0
- package/data/templates/responses.jsonl +1 -0
- package/package.json +5 -1
- package/src/ask-vocab.mjs +39 -1
- package/src/ask.mjs +278 -32
- package/src/chat.mjs +681 -212
- package/src/completions/complete.mjs +138 -0
- package/src/completions/group.mjs +171 -0
- package/src/completions/infer.mjs +395 -0
- package/src/completions/prune.mjs +156 -0
- package/src/completions/rank.mjs +154 -0
- package/src/completions/search.mjs +85 -0
- package/src/corpus/conceptnet.mjs +36 -3
- package/src/corpus/unknown-ingest.mjs +209 -0
- package/src/extensions.mjs +14 -4
- package/src/finish.mjs +61 -18
- package/src/grammar/ace.mjs +24 -338
- package/src/grammar/lexicon.mjs +37 -194
- package/src/interpret/pipeline.mjs +23 -2
- package/src/interpret/strategies/constructions.mjs +207 -0
- package/src/interpret/strategies/grammar.mjs +24 -3
- package/src/interpret/strategies/keywords.mjs +34 -0
- package/src/memory/blocks.mjs +7 -2
- package/src/memory/core.mjs +283 -20
- package/src/memory/shacl.mjs +114 -0
- package/src/prose.mjs +5 -1
- package/src/syllogise.mjs +0 -0
- package/src/grammar/lexicon-core.json +0 -302
package/src/memory/core.mjs
CHANGED
|
@@ -33,6 +33,8 @@ import { dirname, join } from "node:path";
|
|
|
33
33
|
import { proseTokensFor, buildProseIndex } from "../prose.mjs";
|
|
34
34
|
import { fnv1aHex } from "../hash.mjs";
|
|
35
35
|
import { computeTrust, sessionReliabilityFrom, TRUST_SCORE_PROP, TRUST_INPUTS_PROP } from "./trust.mjs";
|
|
36
|
+
import { assertIndividualValid } from "./shacl.mjs";
|
|
37
|
+
import { findActionPath, findReachableSet } from "../planning.mjs";
|
|
36
38
|
|
|
37
39
|
export const MEMORY_DIR_REL = join(".tmct", "memory");
|
|
38
40
|
export const MEMORY_GRAPH_REL = join(MEMORY_DIR_REL, "graph.json");
|
|
@@ -269,10 +271,18 @@ export async function loadMemory(dir) {
|
|
|
269
271
|
* Fact still carrying only the old mgx:factProvenance string gets its Sources +
|
|
270
272
|
* statedBy edges + trust materialised on the next write of any kind. Part B3's
|
|
271
273
|
* actor-level (session-scoped) Source reliability rides the SAME cycle, after
|
|
272
|
-
* migration (so it sees every Fact's Sources, migrated or not).
|
|
274
|
+
* migration (so it sees every Fact's Sources, migrated or not).
|
|
275
|
+
*
|
|
276
|
+
* `fn` may be async (PLAN_AGENTS.md §2.1's SHACL ingest gate: appendFact/
|
|
277
|
+
* appendRule build their candidate individual, `await assertIndividualValid`
|
|
278
|
+
* it, and only then upsert — all inside `fn`, so a rejection throws before
|
|
279
|
+
* ANY mutation of `payload` happens and this function's write is never
|
|
280
|
+
* reached). `await fn(payload)` is a documented no-op for every existing
|
|
281
|
+
* SYNC caller (appendUtterance(s), appendFacts) — awaiting a non-Promise
|
|
282
|
+
* value just resolves to it, byte-identical behaviour to calling it plain. */
|
|
273
283
|
async function mutateMemory(dir, fn) {
|
|
274
284
|
const payload = await loadMemory(dir);
|
|
275
|
-
const out = fn(payload) ?? payload;
|
|
285
|
+
const out = (await fn(payload)) ?? payload;
|
|
276
286
|
migrateLegacyProvenance(out);
|
|
277
287
|
recomputeSourceReliability(out);
|
|
278
288
|
out.proseIndex = buildProseIndex(out.individuals);
|
|
@@ -408,11 +418,22 @@ function statedByObjectsFor(payload, factId) {
|
|
|
408
418
|
}
|
|
409
419
|
|
|
410
420
|
/** Recompute + materialise a Fact's trust cache (mgx:trustScore + the auditable
|
|
411
|
-
* mgx:trustInputs). Called exactly where a statedBy edge could have changed.
|
|
412
|
-
|
|
421
|
+
* mgx:trustInputs). Called exactly where a statedBy edge could have changed.
|
|
422
|
+
* `trustOpts` (optional) is the entailed hook (trust.mjs `computeTrust`'s
|
|
423
|
+
* `premiseTrusts`/`ruleConfidence`) — threaded through from appendFact/
|
|
424
|
+
* appendFacts's own opts so a rule (e.g. syllogise.mjs's cax-dw) can make its
|
|
425
|
+
* conclusion's trust premise-derived (`min(premiseTrusts) × ruleConfidence`)
|
|
426
|
+
* instead of riding the bare entailed prior. Absent (the default, `{}`), this
|
|
427
|
+
* is a no-op passthrough — every existing caller's score is byte-identical
|
|
428
|
+
* (PLAN_INFERENCE_TESTING.md §4 stage 2's exit criterion). */
|
|
429
|
+
function recomputeFactTrust(payload, fact, nowMs = Date.now(), trustOpts = {}) {
|
|
413
430
|
const sourceIds = statedByObjectsFor(payload, fact.id);
|
|
414
431
|
const createdAt = (fact.attributes || []).find((a) => a?.prop === CREATED_AT_PROP)?.value || "";
|
|
415
|
-
const { score, inputs } = computeTrust({ sourceIds, createdAt }, sourcesByIdMap(payload), {
|
|
432
|
+
const { score, inputs } = computeTrust({ sourceIds, createdAt }, sourcesByIdMap(payload), {
|
|
433
|
+
now: nowMs,
|
|
434
|
+
...(Array.isArray(trustOpts?.premiseTrusts) ? { premiseTrusts: trustOpts.premiseTrusts } : {}),
|
|
435
|
+
...(typeof trustOpts?.ruleConfidence === "number" ? { ruleConfidence: trustOpts.ruleConfidence } : {}),
|
|
436
|
+
});
|
|
416
437
|
setAttr(fact, TRUST_SCORE_PROP, "trustScore", String(score));
|
|
417
438
|
setAttr(fact, TRUST_INPUTS_PROP, "trustInputs", JSON.stringify(inputs));
|
|
418
439
|
}
|
|
@@ -420,8 +441,9 @@ function recomputeFactTrust(payload, fact, nowMs = Date.now()) {
|
|
|
420
441
|
/** Reconcile a Fact's Sources + statedBy edges with its (unchanged, compat)
|
|
421
442
|
* mgx:factProvenance string, then recompute its trust. ADD-only over
|
|
422
443
|
* deterministic Source ids and upsertEdge's subject>object dedupe, so it is
|
|
423
|
-
* idempotent and NEVER re-keys the fact (its id still hashes only (s,p,o)).
|
|
424
|
-
|
|
444
|
+
* idempotent and NEVER re-keys the fact (its id still hashes only (s,p,o)).
|
|
445
|
+
* `trustOpts` passes straight through to recomputeFactTrust (see there). */
|
|
446
|
+
function syncFactSources(payload, fact, nowMs = Date.now(), trustOpts = {}) {
|
|
425
447
|
const prov = (fact.attributes || []).find((a) => a?.prop === "mgx:factProvenance")?.value || "";
|
|
426
448
|
// a Source's createdAt candidate is the FIRST stating fact's createdAt (its
|
|
427
449
|
// "first seen"), falling back to now — first-write-wins keeps the earliest.
|
|
@@ -435,7 +457,7 @@ function syncFactSources(payload, fact, nowMs = Date.now()) {
|
|
|
435
457
|
subject: fact.id, object: sid, subjectLabel: fact.label, objectLabel: sourceLabel(sid),
|
|
436
458
|
});
|
|
437
459
|
}
|
|
438
|
-
recomputeFactTrust(payload, fact, nowMs);
|
|
460
|
+
recomputeFactTrust(payload, fact, nowMs, trustOpts);
|
|
439
461
|
}
|
|
440
462
|
|
|
441
463
|
/** Lazy, idempotent migration of the legacy provenance union (step (b)): any
|
|
@@ -685,8 +707,17 @@ const factIdFor = (s, p, o) => `fact:${fnv1aHex(`${s}\0${p}\0${o}`)}`;
|
|
|
685
707
|
/** Append one grammar-derived OWL triple, RDF-reified: a `Fact` individual
|
|
686
708
|
* carrying rdf:subject / rdf:predicate / rdf:object (+ provenance). The
|
|
687
709
|
* Phase-2 ACE parser's write point. Same (s,p,o) → same id → upsert, never a
|
|
688
|
-
* duplicate.
|
|
689
|
-
|
|
710
|
+
* duplicate. `premiseTrusts`/`ruleConfidence` (optional) engage trust.mjs's
|
|
711
|
+
* entailed hook — see recomputeFactTrust; a rule-derived write (e.g.
|
|
712
|
+
* syllogise.mjs) passes these, a plain taught/asserted write omits them and
|
|
713
|
+
* is byte-identical to before.
|
|
714
|
+
*
|
|
715
|
+
* PLAN_AGENTS.md 2.1's SHACL ingest gate: the candidate Fact individual is
|
|
716
|
+
* validated against ontology/memory-shapes.ttl (memory/shacl.mjs) BEFORE
|
|
717
|
+
* upsertIndividual runs -- a violation throws here, inside mutateMemory's
|
|
718
|
+
* `fn`, so the write never happens (mutateMemory's atomic write is never
|
|
719
|
+
* reached; the on-disk graph is untouched). Returns { id }. */
|
|
720
|
+
export async function appendFact(dir, { subject, predicate, object, provenance = "", createdAt = "", quantifier = "", premiseTrusts, ruleConfidence } = {}) {
|
|
690
721
|
const s = normFactTerm(subject);
|
|
691
722
|
const p = normText(predicate);
|
|
692
723
|
const o = normFactTerm(object);
|
|
@@ -695,7 +726,7 @@ export async function appendFact(dir, { subject, predicate, object, provenance =
|
|
|
695
726
|
const text = `${s} ${p} ${o}`;
|
|
696
727
|
const tokens = proseTokensFor({ doc: text });
|
|
697
728
|
const q = normText(quantifier);
|
|
698
|
-
await mutateMemory(dir, (payload) => {
|
|
729
|
+
await mutateMemory(dir, async (payload) => {
|
|
699
730
|
const prior = payload.individuals.find((x) => x?.id === id);
|
|
700
731
|
const priorProv = prior?.attributes?.find((a) => a?.prop === "mgx:factProvenance")?.value || "";
|
|
701
732
|
// The mgx:factProvenance union stays BYTE-IDENTICAL (a compat shim readers
|
|
@@ -706,7 +737,7 @@ export async function appendFact(dir, { subject, predicate, object, provenance =
|
|
|
706
737
|
// plain re-teach, never SILENTLY erases an already-recorded quantifier).
|
|
707
738
|
const priorQ = prior?.attributes?.find((a) => a?.prop === "mgx:factQuantifier")?.value || "";
|
|
708
739
|
const qVal = q || priorQ;
|
|
709
|
-
|
|
740
|
+
const candidate = {
|
|
710
741
|
id, label: labelOf(text), class: FACT_CLASS,
|
|
711
742
|
derived_from: [], mentions: [],
|
|
712
743
|
attributes: [
|
|
@@ -719,10 +750,12 @@ export async function appendFact(dir, { subject, predicate, object, provenance =
|
|
|
719
750
|
...(tokens.length ? [{ prop: "mgx:hasProseTokens", key: "prose_tokens", value: tokens.join(" ") }] : []),
|
|
720
751
|
...(qVal ? [{ prop: "mgx:factQuantifier", key: "quantifier", value: qVal }] : []),
|
|
721
752
|
],
|
|
722
|
-
}
|
|
753
|
+
};
|
|
754
|
+
await assertIndividualValid(candidate); // the SHACL gate -- throws, never writes, on a violation
|
|
755
|
+
upsertIndividual(payload, candidate);
|
|
723
756
|
// Derive Source individuals + statedBy edges from the provenance union and
|
|
724
757
|
// (re)materialise this fact's trust — the live half of steps (b)/(c).
|
|
725
|
-
syncFactSources(payload, payload.individuals.find((x) => x?.id === id));
|
|
758
|
+
syncFactSources(payload, payload.individuals.find((x) => x?.id === id), undefined, { premiseTrusts, ruleConfidence });
|
|
726
759
|
recountClasses(payload);
|
|
727
760
|
});
|
|
728
761
|
return { id };
|
|
@@ -743,6 +776,10 @@ export async function appendFact(dir, { subject, predicate, object, provenance =
|
|
|
743
776
|
* same statedBy Source edges, same mgx:trustScore, same first-write-wins
|
|
744
777
|
* createdAt. Malformed facts (missing subject/predicate/object) are SKIPPED (a
|
|
745
778
|
* bad row never aborts a 6 k-fact seed), not thrown as appendFact does.
|
|
779
|
+
* Each fact may also carry `premiseTrusts`/`ruleConfidence` (optional) —
|
|
780
|
+
* appendFact's own entailed-hook passthrough, batched: syllogise.mjs's
|
|
781
|
+
* materializing pass is this function's main caller, so this is the write
|
|
782
|
+
* path a rule's conclusion trust actually rides (recomputeFactTrust, above).
|
|
746
783
|
* Returns { ids, appended, skipped } — ids one per applied fact (in order),
|
|
747
784
|
* appended = ids.length, skipped = malformed count. */
|
|
748
785
|
export async function appendFacts(dir, facts) {
|
|
@@ -761,6 +798,8 @@ export async function appendFacts(dir, facts) {
|
|
|
761
798
|
provenance: normText(f?.provenance),
|
|
762
799
|
createdAt: f?.createdAt || "",
|
|
763
800
|
quantifier: normText(f?.quantifier),
|
|
801
|
+
premiseTrusts: Array.isArray(f?.premiseTrusts) ? f.premiseTrusts : undefined,
|
|
802
|
+
ruleConfidence: typeof f?.ruleConfidence === "number" ? f.ruleConfidence : undefined,
|
|
764
803
|
});
|
|
765
804
|
}
|
|
766
805
|
const ids = [];
|
|
@@ -770,6 +809,7 @@ export async function appendFacts(dir, facts) {
|
|
|
770
809
|
const byId = new Map(payload.individuals.map((i) => [i?.id, i]));
|
|
771
810
|
const touched = [];
|
|
772
811
|
const seen = new Set();
|
|
812
|
+
const trustOptsById = new Map();
|
|
773
813
|
for (const f of prepared) {
|
|
774
814
|
const prior = byId.get(f.id);
|
|
775
815
|
const priorProv = prior?.attributes?.find((a) => a?.prop === "mgx:factProvenance")?.value || "";
|
|
@@ -800,10 +840,19 @@ export async function appendFacts(dir, facts) {
|
|
|
800
840
|
byId.set(f.id, ind);
|
|
801
841
|
ids.push(f.id);
|
|
802
842
|
if (!seen.has(f.id)) { seen.add(f.id); touched.push(f.id); }
|
|
843
|
+
// Last-prepared-row-wins per id for the trust hook opts (mirrors the
|
|
844
|
+
// provenance/quantifier/ind upsert above, which is also last-wins per id
|
|
845
|
+
// within one batch — a duplicate id inside the same call is rare, but
|
|
846
|
+
// when it happens the SAME single-write-per-id discipline applies here).
|
|
847
|
+
if (f.premiseTrusts !== undefined || f.ruleConfidence !== undefined) {
|
|
848
|
+
trustOptsById.set(f.id, { premiseTrusts: f.premiseTrusts, ruleConfidence: f.ruleConfidence });
|
|
849
|
+
}
|
|
803
850
|
}
|
|
804
851
|
// Reconcile each touched fact's Sources + trust once (add-only, idempotent),
|
|
805
|
-
// then recount classes a SINGLE time at the end.
|
|
806
|
-
|
|
852
|
+
// then recount classes a SINGLE time at the end. trustOptsById threads the
|
|
853
|
+
// entailed hook (recomputeFactTrust, above) per fact — absent for a fact
|
|
854
|
+
// that didn't declare premiseTrusts, so its trust is unchanged from before.
|
|
855
|
+
for (const id of touched) syncFactSources(payload, byId.get(id), undefined, trustOptsById.get(id));
|
|
807
856
|
recountClasses(payload);
|
|
808
857
|
});
|
|
809
858
|
return { ids, appended: ids.length, skipped };
|
|
@@ -861,7 +910,12 @@ const ruleIdFor = (kind, name, slot1, slot2) => `rule:${fnv1aHex(`${kind}\0${nam
|
|
|
861
910
|
* pipeline appendFact uses, unmodified — neither function ever checks
|
|
862
911
|
* `individual.class`, so a Rule carrying the same mgx:factProvenance compat
|
|
863
912
|
* attribute + CREATED_AT_PROP gets the same Source-derivation + trust score an
|
|
864
|
-
* ordinary Fact would.
|
|
913
|
+
* ordinary Fact would.
|
|
914
|
+
*
|
|
915
|
+
* PLAN_AGENTS.md 2.1's SHACL ingest gate: the candidate Rule individual is
|
|
916
|
+
* validated against ontology/memory-shapes.ttl (memory/shacl.mjs) BEFORE
|
|
917
|
+
* upsertIndividual runs, same discipline as appendFact -- a violation
|
|
918
|
+
* throws before mutateMemory's write is ever reached. Returns { id }. */
|
|
865
919
|
export async function appendRule(dir, { name, kind, slots, provenance = "", createdAt = "" } = {}) {
|
|
866
920
|
const spec = RULE_SLOT_SPEC[kind];
|
|
867
921
|
if (!spec) throw new Error(`a rule kind must be one of ${RULE_KINDS.join(", ")}, got ${JSON.stringify(kind)}`);
|
|
@@ -873,14 +927,14 @@ export async function appendRule(dir, { name, kind, slots, provenance = "", crea
|
|
|
873
927
|
}
|
|
874
928
|
const id = ruleIdFor(kind, n, slotValues[0], slotValues[1]);
|
|
875
929
|
const label = labelOf(`${n} = ${kind}(${slotValues.join(", ")})`);
|
|
876
|
-
await mutateMemory(dir, (payload) => {
|
|
930
|
+
await mutateMemory(dir, async (payload) => {
|
|
877
931
|
const prior = payload.individuals.find((x) => x?.id === id);
|
|
878
932
|
const priorProv = prior?.attributes?.find((a) => a?.prop === "mgx:factProvenance")?.value || "";
|
|
879
933
|
// Same union-of-tags discipline as appendFact — the compat string stays
|
|
880
934
|
// byte-identical in spirit; the Source edges below are DERIVED from it.
|
|
881
935
|
const provs = [...new Set([...priorProv.split(" | "), normText(provenance)].filter(Boolean))];
|
|
882
936
|
const createdAtVal = firstWriteCreatedAt(prior, createdAt); // first-write-wins
|
|
883
|
-
|
|
937
|
+
const candidate = {
|
|
884
938
|
id, label, class: RULE_CLASS,
|
|
885
939
|
derived_from: [], mentions: [],
|
|
886
940
|
attributes: [
|
|
@@ -891,7 +945,9 @@ export async function appendRule(dir, { name, kind, slots, provenance = "", crea
|
|
|
891
945
|
{ prop: CREATED_AT_PROP, key: "createdAt", value: createdAtVal },
|
|
892
946
|
...(provs.length ? [{ prop: "mgx:factProvenance", key: "provenance", value: provs.join(" | ") }] : []),
|
|
893
947
|
],
|
|
894
|
-
}
|
|
948
|
+
};
|
|
949
|
+
await assertIndividualValid(candidate); // the SHACL gate -- throws, never writes, on a violation
|
|
950
|
+
upsertIndividual(payload, candidate);
|
|
895
951
|
// Same Source-derivation + trust-materialisation call appendFact makes —
|
|
896
952
|
// syncFactSources/recomputeFactTrust only ever touch fact.attributes/id/
|
|
897
953
|
// label, never fact.class, so a Rule individual rides it unmodified.
|
|
@@ -915,6 +971,213 @@ export function findRuleByName(memory, name) {
|
|
|
915
971
|
);
|
|
916
972
|
}
|
|
917
973
|
|
|
974
|
+
// ---- Relation chase (extracted from chat.mjs's (a0)/(a0.2) blocks,
|
|
975
|
+
// PLAN_TAUGHT_RELATIONS.md Phase 2/4/5; PLAN_COMPLETIONS.md Stage 1
|
|
976
|
+
// prerequisite) --------------------------------------------------------------
|
|
977
|
+
//
|
|
978
|
+
// `resolveRelationChase` and `resolveRelationChaseReverse` were originally
|
|
979
|
+
// unexported closures inside chat.mjs's factReadBack, coupled to its own
|
|
980
|
+
// local `rows`/`memoryDir`/`byTrust`/`renderFactLine`/`factPhrase`/
|
|
981
|
+
// `factTermVariants` variables. Moved here — findRuleByName's natural
|
|
982
|
+
// sibling, since this file already owns Rule storage/lookup — as plain,
|
|
983
|
+
// standalone, importable functions so Stage 1's cross-group inference can
|
|
984
|
+
// reuse the SAME resolution logic outside chat.mjs's dispatch context. The
|
|
985
|
+
// closures they used to capture are now explicit parameters: `memory` (an
|
|
986
|
+
// already-loaded loadMemory() payload — callers load it once, not per
|
|
987
|
+
// recursive call) and a `helpers` bag carrying every chat.mjs-local piece
|
|
988
|
+
// they relied on (`relationFactsFor`, `renderFactLine`, `factPhrase`,
|
|
989
|
+
// `factTermVariants`, `byTrust`, the trust-bearing `rows` array, and
|
|
990
|
+
// `HAS_PROPERTY_PREDICATE`). No other chat.mjs coupling remains — dynamic
|
|
991
|
+
// imports of this file's own findRuleByName/RULE_KIND_* and of
|
|
992
|
+
// planning.mjs's findActionPath/findReachableSet are now direct references/
|
|
993
|
+
// static imports, since both now live alongside or are reachable from here
|
|
994
|
+
// without a cycle (planning.mjs imports nothing).
|
|
995
|
+
//
|
|
996
|
+
// Behavior is unchanged from the original closures: same dispatch order
|
|
997
|
+
// (direct/alias fact hit → compose2 rule chase → filter rule chase → honest
|
|
998
|
+
// miss), same OWA discipline (null / [] on a miss, never a guessed "no").
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* RELATION CHASE (chat.mjs's (a0) block) — given a relation/rule NAME and a
|
|
1002
|
+
* fixed (subject, object) pair, resolve whether it holds: (i) a direct taught
|
|
1003
|
+
* fact, (ii) the same pair reached via an alias-chased predicate (rdfs:subClassOf
|
|
1004
|
+
* over relation-name strings, folded into `relationFactsFor`'s own candidate
|
|
1005
|
+
* list), (iii) a hop-counted compose2 Rule chase (exactly 2 hops: base1 then
|
|
1006
|
+
* base2), or (iv) a filter Rule chase (recursively resolve the base, then
|
|
1007
|
+
* require the subject also carry the taught property). Returns
|
|
1008
|
+
* `{ citation: string[] }` on a genuine hit, or null on an honest miss.
|
|
1009
|
+
*/
|
|
1010
|
+
export async function resolveRelationChase(memory, name, subjectTerm, objectTerm, helpers) {
|
|
1011
|
+
const { relationFactsFor, renderFactLine, factPhrase, factTermVariants, byTrust, rows, HAS_PROPERTY_PREDICATE } = helpers;
|
|
1012
|
+
const target = String(name || "").trim().toLowerCase();
|
|
1013
|
+
// (i)+(ii): direct hit or alias-chased hit for this exact (subject, object)
|
|
1014
|
+
// pair under the queried relation name.
|
|
1015
|
+
const sv = factTermVariants(normFactTerm, subjectTerm);
|
|
1016
|
+
const ov = factTermVariants(normFactTerm, objectTerm);
|
|
1017
|
+
const pairHits = relationFactsFor(target).filter((e) => sv.has(e.fact.subject) && ov.has(e.fact.object));
|
|
1018
|
+
if (pairHits.length) {
|
|
1019
|
+
const hit = pairHits.slice().sort((a, b) => byTrust(a.fact, b.fact))[0];
|
|
1020
|
+
return { citation: [renderFactLine(hit.fact), ...hit.aliasFacts.map(
|
|
1021
|
+
(af) => `${factPhrase(af)}${af.provenance ? ` (source: ${af.provenance})` : ""}`,
|
|
1022
|
+
)] };
|
|
1023
|
+
}
|
|
1024
|
+
// The queried name may itself be a taught RULE.
|
|
1025
|
+
const rule = findRuleByName(memory, target);
|
|
1026
|
+
const ruleKind = rule?.attributes?.find((a) => a.prop === RULE_KIND_PROP)?.value;
|
|
1027
|
+
// (iii) COMPOSE2 RULE CHASE — a hop-counted findActionPath search over
|
|
1028
|
+
// { entity, hopsTaken } states, dispatching base1's edges at hop 0 and
|
|
1029
|
+
// base2's edges at hop 1, requiring EXACTLY hopsTaken === 2 at the goal.
|
|
1030
|
+
if (rule && ruleKind === RULE_KIND_COMPOSE2) {
|
|
1031
|
+
const base1 = rule.attributes.find((a) => a.prop === "mgx:ruleBase1")?.value;
|
|
1032
|
+
const base2 = rule.attributes.find((a) => a.prop === "mgx:ruleBase2")?.value;
|
|
1033
|
+
const startEntity = normFactTerm(subjectTerm);
|
|
1034
|
+
const targetEntity = normFactTerm(objectTerm);
|
|
1035
|
+
if (!base1 || !base2 || !startEntity || !targetEntity) return null;
|
|
1036
|
+
const applyActions = (state) => {
|
|
1037
|
+
if (state.hopsTaken >= 2) return [];
|
|
1038
|
+
const relName = state.hopsTaken === 0 ? base1 : base2;
|
|
1039
|
+
return relationFactsFor(relName)
|
|
1040
|
+
.filter((e) => e.fact.subject === state.entity)
|
|
1041
|
+
.map((e) => ({ action: e, nextState: { entity: e.fact.object, hopsTaken: state.hopsTaken + 1 } }));
|
|
1042
|
+
};
|
|
1043
|
+
const isGoal = (state) => state.hopsTaken === 2 && state.entity === targetEntity;
|
|
1044
|
+
const stateKey = (state) => `${state.entity}#${state.hopsTaken}`;
|
|
1045
|
+
const found = findActionPath({ entity: startEntity, hopsTaken: 0 }, isGoal, applyActions, { maxDepth: 2, stateKey });
|
|
1046
|
+
if (!found) return null;
|
|
1047
|
+
const seenAlias = new Set();
|
|
1048
|
+
const parts = [];
|
|
1049
|
+
for (const e of found.actions) {
|
|
1050
|
+
parts.push(renderFactLine(e.fact));
|
|
1051
|
+
for (const af of e.aliasFacts) {
|
|
1052
|
+
const key = af.id || `${af.subject}|${af.predicate}|${af.object}`;
|
|
1053
|
+
if (seenAlias.has(key)) continue;
|
|
1054
|
+
seenAlias.add(key);
|
|
1055
|
+
parts.push(`${factPhrase(af)}${af.provenance ? ` (source: ${af.provenance})` : ""}`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return { citation: parts };
|
|
1059
|
+
}
|
|
1060
|
+
// (iv) FILTER RULE CHASE — recursively resolve the base (a plain relation OR
|
|
1061
|
+
// another rule — this SAME function calls itself), then filter by whether
|
|
1062
|
+
// the SUBJECT carries the property literal (mgx:hasProperty, a plain Fact
|
|
1063
|
+
// lookup over the already-loaded `rows`).
|
|
1064
|
+
if (rule && ruleKind === RULE_KIND_FILTER) {
|
|
1065
|
+
const base = rule.attributes.find((a) => a.prop === "mgx:ruleBase1")?.value;
|
|
1066
|
+
const property = rule.attributes.find((a) => a.prop === "mgx:ruleFilterProperty")?.value;
|
|
1067
|
+
if (!base || !property) return null;
|
|
1068
|
+
const baseHit = await resolveRelationChase(memory, base, subjectTerm, objectTerm, helpers);
|
|
1069
|
+
if (!baseHit) return null;
|
|
1070
|
+
const subjectEntity = normFactTerm(subjectTerm);
|
|
1071
|
+
const propertyNorm = normFactTerm(property);
|
|
1072
|
+
const propHit = rows.find(
|
|
1073
|
+
(f) => f.predicate === HAS_PROPERTY_PREDICATE && f.subject === subjectEntity && normFactTerm(f.object) === propertyNorm,
|
|
1074
|
+
);
|
|
1075
|
+
if (!propHit) return null; // base relation holds, but the property filter excludes this candidate
|
|
1076
|
+
return { citation: [...baseHit.citation, renderFactLine(propHit)] };
|
|
1077
|
+
}
|
|
1078
|
+
return null; // no remembered fact, alias, or rule (of any kind) reaches this
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* RELATION "WHO" REVERSE CHASE (chat.mjs's (a0.2) block) — the mirror image of
|
|
1083
|
+
* resolveRelationChase: given a relation/rule name and a FIXED OBJECT, return
|
|
1084
|
+
* every `{ subject, citation }` pair that satisfies it, instead of a single
|
|
1085
|
+
* yes/no for a fixed (subject, object) pair. Recursion is bounded the SAME way
|
|
1086
|
+
* resolveRelationChase's own filter chase is: a filter rule's base is always
|
|
1087
|
+
* either a plain relation (terminal) or another rule (one level deeper), never
|
|
1088
|
+
* itself.
|
|
1089
|
+
*/
|
|
1090
|
+
export async function resolveRelationChaseReverse(memory, name, objectTerm, helpers) {
|
|
1091
|
+
const { relationFactsFor, renderFactLine, factPhrase, factTermVariants, byTrust, rows, HAS_PROPERTY_PREDICATE } = helpers;
|
|
1092
|
+
const target = String(name || "").trim().toLowerCase();
|
|
1093
|
+
const ov = factTermVariants(normFactTerm, objectTerm);
|
|
1094
|
+
// (i)+(ii): every direct/alias-chased fact under this name whose object
|
|
1095
|
+
// matches the target — one result per distinct subject (the highest-trust
|
|
1096
|
+
// fact when more than one reaches the same subject).
|
|
1097
|
+
const directHits = relationFactsFor(target).filter((e) => ov.has(e.fact.object));
|
|
1098
|
+
if (directHits.length) {
|
|
1099
|
+
const bySubject = new Map();
|
|
1100
|
+
for (const e of directHits) {
|
|
1101
|
+
if (!bySubject.has(e.fact.subject)) bySubject.set(e.fact.subject, []);
|
|
1102
|
+
bySubject.get(e.fact.subject).push(e);
|
|
1103
|
+
}
|
|
1104
|
+
return [...bySubject.entries()].map(([subj, hits]) => {
|
|
1105
|
+
const hit = hits.slice().sort((a, b) => byTrust(a.fact, b.fact))[0];
|
|
1106
|
+
return {
|
|
1107
|
+
subject: subj,
|
|
1108
|
+
citation: [renderFactLine(hit.fact), ...hit.aliasFacts.map(
|
|
1109
|
+
(af) => `${factPhrase(af)}${af.provenance ? ` (source: ${af.provenance})` : ""}`,
|
|
1110
|
+
)],
|
|
1111
|
+
};
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
const rule = findRuleByName(memory, target);
|
|
1115
|
+
const ruleKind = rule?.attributes?.find((a) => a.prop === RULE_KIND_PROP)?.value;
|
|
1116
|
+
// (iii) COMPOSE2 REVERSE CHASE — the same hop-counted search the forward
|
|
1117
|
+
// chase uses, walked BACKWARD: seed from the TARGET object, reverse-hop via
|
|
1118
|
+
// base2's edges first (the SECOND forward hop, closest to the object), then
|
|
1119
|
+
// base1's edges (the FIRST forward hop) — swapping which side of each fact
|
|
1120
|
+
// is queried (object instead of subject) rather than building a new search
|
|
1121
|
+
// kernel. Enumerates every subject reachable at EXACTLY 2 reverse hops, via
|
|
1122
|
+
// findReachableSet.
|
|
1123
|
+
if (rule && ruleKind === RULE_KIND_COMPOSE2) {
|
|
1124
|
+
const base1 = rule.attributes.find((a) => a.prop === "mgx:ruleBase1")?.value;
|
|
1125
|
+
const base2 = rule.attributes.find((a) => a.prop === "mgx:ruleBase2")?.value;
|
|
1126
|
+
const targetEntity = normFactTerm(objectTerm);
|
|
1127
|
+
if (!base1 || !base2 || !targetEntity) return [];
|
|
1128
|
+
const applyActionsRev = (state) => {
|
|
1129
|
+
if (state.hopsTaken >= 2) return [];
|
|
1130
|
+
const relName = state.hopsTaken === 0 ? base2 : base1;
|
|
1131
|
+
return relationFactsFor(relName)
|
|
1132
|
+
.filter((e) => e.fact.object === state.entity)
|
|
1133
|
+
.map((e) => ({ action: e, nextState: { entity: e.fact.subject, hopsTaken: state.hopsTaken + 1 } }));
|
|
1134
|
+
};
|
|
1135
|
+
const stateKeyRev = (state) => `${state.entity}#${state.hopsTaken}`;
|
|
1136
|
+
const reached = findReachableSet(
|
|
1137
|
+
{ entity: targetEntity, hopsTaken: 0 }, applyActionsRev, { maxDepth: 2, stateKey: stateKeyRev },
|
|
1138
|
+
);
|
|
1139
|
+
return reached.filter((r) => r.node.hopsTaken === 2).map(({ node, path }) => {
|
|
1140
|
+
const seenAlias = new Set();
|
|
1141
|
+
const parts = [];
|
|
1142
|
+
// path.actions was accumulated walking BACKWARD from the object (base2's
|
|
1143
|
+
// edge first, base1's edge second) — reversed here so the citation reads
|
|
1144
|
+
// in the natural subject-to-object order, matching the forward chase's
|
|
1145
|
+
// own citation order rather than exposing the reverse-walk's internal
|
|
1146
|
+
// accumulation order to the caller.
|
|
1147
|
+
for (const e of path.actions.slice().reverse()) {
|
|
1148
|
+
parts.push(renderFactLine(e.fact));
|
|
1149
|
+
for (const af of e.aliasFacts) {
|
|
1150
|
+
const key = af.id || `${af.subject}|${af.predicate}|${af.object}`;
|
|
1151
|
+
if (seenAlias.has(key)) continue;
|
|
1152
|
+
seenAlias.add(key);
|
|
1153
|
+
parts.push(`${factPhrase(af)}${af.provenance ? ` (source: ${af.provenance})` : ""}`);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
return { subject: node.entity, citation: parts };
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
// (iv) FILTER REVERSE CHASE — reverse-chase the base (recursively, same as
|
|
1160
|
+
// the forward filter chase — this SAME function calls itself), then filter
|
|
1161
|
+
// the resulting subjects by whether EACH carries the taught property.
|
|
1162
|
+
if (rule && ruleKind === RULE_KIND_FILTER) {
|
|
1163
|
+
const base = rule.attributes.find((a) => a.prop === "mgx:ruleBase1")?.value;
|
|
1164
|
+
const property = rule.attributes.find((a) => a.prop === "mgx:ruleFilterProperty")?.value;
|
|
1165
|
+
if (!base || !property) return [];
|
|
1166
|
+
const baseHits = await resolveRelationChaseReverse(memory, base, objectTerm, helpers);
|
|
1167
|
+
const propertyNorm = normFactTerm(property);
|
|
1168
|
+
const out = [];
|
|
1169
|
+
for (const bh of baseHits) {
|
|
1170
|
+
const subjectEntity = normFactTerm(bh.subject);
|
|
1171
|
+
const propHit = rows.find(
|
|
1172
|
+
(f) => f.predicate === HAS_PROPERTY_PREDICATE && f.subject === subjectEntity && normFactTerm(f.object) === propertyNorm,
|
|
1173
|
+
);
|
|
1174
|
+
if (propHit) out.push({ subject: bh.subject, citation: [...bh.citation, renderFactLine(propHit)] });
|
|
1175
|
+
}
|
|
1176
|
+
return out;
|
|
1177
|
+
}
|
|
1178
|
+
return []; // no remembered fact, alias, or rule (of any kind) reaches this
|
|
1179
|
+
}
|
|
1180
|
+
|
|
918
1181
|
// ---- Chat-facing seams (W4 fact lookup + contradiction) ---------------------
|
|
919
1182
|
// The W4 fact-lookup THREADING lives in chat.mjs (NOT here); these pure readers
|
|
920
1183
|
// are the seam it calls so the answer layer ranks candidates by relevance ×
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// memory/shacl.mjs — the declarative SHACL-STYLE ingest gate for tmct's own
|
|
2
|
+
// memory graph (PLAN_AGENTS.md §2.1 "Declarative SHACL ingest gate (c)").
|
|
3
|
+
//
|
|
4
|
+
// ontology/memory-shapes.ttl is the canonical, standards-based (SHACL/
|
|
5
|
+
// Turtle) declarative SPEC for these three shapes — written first, kept as
|
|
6
|
+
// the human-readable/auditable contract, modelled on marginalia's own
|
|
7
|
+
// app/ontology/shapes.ttl. This file is a small, HAND-ROLLED validator that
|
|
8
|
+
// implements exactly what that spec describes, in plain JS, against
|
|
9
|
+
// memory/core.mjs's own {id, label, class, attributes} individual shape.
|
|
10
|
+
//
|
|
11
|
+
// Deliberately NOT wired to a real SHACL/RDF-JS engine. `shacl-engine` +
|
|
12
|
+
// `rdf-ext` were tried first (the plan's named tooling, matching
|
|
13
|
+
// marginalia's own choice) and rejected on measurement: shacl-engine
|
|
14
|
+
// transitively pulls in `@comunica/query-sparql-rdfjs-lite` — a full
|
|
15
|
+
// federated SPARQL query engine — across 560+ packages (~7700 added
|
|
16
|
+
// package-lock.json lines), wildly disproportionate to tmct's "pure-JS,
|
|
17
|
+
// minimal-deps" floor (5 runtime deps before this) and to what three closed,
|
|
18
|
+
// bounded shapes actually need. `src/conformance.mjs` already proves this
|
|
19
|
+
// project is comfortable with imperative shape assertions instead of a
|
|
20
|
+
// general engine (a Repository-Interface contract-test suite, not a memory
|
|
21
|
+
// gate — a DIFFERENT thing from this file, see its own header); this module
|
|
22
|
+
// is the same discipline applied to memory-write validation. Keep
|
|
23
|
+
// ontology/memory-shapes.ttl and this file in sync BY HAND when either shape
|
|
24
|
+
// changes — the .ttl is documentation here, not machine-read.
|
|
25
|
+
//
|
|
26
|
+
// Every shape below is PERMISSIVE beyond memory/core.mjs's own existing
|
|
27
|
+
// structural floor (appendFact/appendRule already throw before ever reaching
|
|
28
|
+
// mutateMemory if subject/predicate/object or name/kind/slots are missing —
|
|
29
|
+
// this gate mirrors, not tightens, that floor) and treats every OPTIONAL
|
|
30
|
+
// attribute (provenance chief among them — appendFact's own signature
|
|
31
|
+
// defaults `provenance` to `""`, and real call sites/tests legitimately omit
|
|
32
|
+
// it, e.g. re-writing createdAt without re-asserting provenance) as OPTIONAL
|
|
33
|
+
// here too: a violation only fires on genuine structural malformation, never
|
|
34
|
+
// on a legitimately sparse-but-valid write or upsert of existing data.
|
|
35
|
+
|
|
36
|
+
const MEMORY_CLASSES = new Set(["Utterance", "Fact", "Session", "Source", "Rule"]);
|
|
37
|
+
const RULE_KINDS = new Set(["compose2", "filter", "recursive"]);
|
|
38
|
+
|
|
39
|
+
// Mirrors core.mjs's own (unexported) RULE_SLOT_SPEC exactly — the single
|
|
40
|
+
// source of truth for the closed compose2/filter/recursive shapes; kept in
|
|
41
|
+
// sync by hand (both describe the SAME three rule kinds' slot pairs).
|
|
42
|
+
const RULE_SLOT_PROPS = {
|
|
43
|
+
compose2: ["mgx:ruleBase1", "mgx:ruleBase2"],
|
|
44
|
+
filter: ["mgx:ruleBase1", "mgx:ruleFilterProperty"],
|
|
45
|
+
recursive: ["mgx:ruleBaseCase", "mgx:ruleRecStep"],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function attrValue(ind, prop) {
|
|
49
|
+
const a = (ind?.attributes || []).find((x) => x?.prop === prop);
|
|
50
|
+
return a ? String(a.value ?? "") : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim().length > 0;
|
|
54
|
+
|
|
55
|
+
/** IndividualShape (ontology/memory-shapes.ttl's mgx:IndividualShape): every
|
|
56
|
+
* memory node must carry a class from the closed memory-class vocabulary. */
|
|
57
|
+
function checkIndividual(ind, violations) {
|
|
58
|
+
if (!ind?.class || !MEMORY_CLASSES.has(ind.class)) {
|
|
59
|
+
violations.push(`must have a class from the closed vocabulary Utterance | Fact | Session | Source | Rule (got ${JSON.stringify(ind?.class)})`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** FactShape (mgx:FactShape): the reified subject/predicate/object, each
|
|
64
|
+
* present and non-empty; mgx:factProvenance, WHEN PRESENT, must be
|
|
65
|
+
* non-empty (optional at this gate — see file header on why: appendFact's
|
|
66
|
+
* own API allows an empty/omitted provenance). */
|
|
67
|
+
function checkFact(ind, violations) {
|
|
68
|
+
for (const prop of ["rdf:subject", "rdf:predicate", "rdf:object"]) {
|
|
69
|
+
if (!nonEmpty(attrValue(ind, prop))) violations.push(`a Fact needs a non-empty ${prop}`);
|
|
70
|
+
}
|
|
71
|
+
const prov = attrValue(ind, "mgx:factProvenance");
|
|
72
|
+
if (prov !== undefined && !nonEmpty(prov)) violations.push("mgx:factProvenance, when present, must be non-empty");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** RuleShape (mgx:RuleShape): a non-empty name; a kind from the closed
|
|
76
|
+
* compose2 | filter | recursive vocabulary; and the matching pair of slots
|
|
77
|
+
* for that declared kind, each present and non-empty (RULE_SLOT_PROPS
|
|
78
|
+
* above). */
|
|
79
|
+
function checkRule(ind, violations) {
|
|
80
|
+
if (!nonEmpty(attrValue(ind, "mgx:ruleName"))) violations.push("a Rule needs a non-empty mgx:ruleName");
|
|
81
|
+
const kind = attrValue(ind, "mgx:ruleKind");
|
|
82
|
+
if (!kind || !RULE_KINDS.has(kind)) {
|
|
83
|
+
violations.push(`a Rule's mgx:ruleKind must be one of compose2 | filter | recursive (got ${JSON.stringify(kind)})`);
|
|
84
|
+
return; // no declared kind to check slots against
|
|
85
|
+
}
|
|
86
|
+
for (const prop of RULE_SLOT_PROPS[kind]) {
|
|
87
|
+
if (!nonEmpty(attrValue(ind, prop))) violations.push(`a ${kind} Rule needs a non-empty ${prop}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Validate one memory individual ({id, label, class, attributes}) against
|
|
92
|
+
* the shapes ontology/memory-shapes.ttl documents. Pure, synchronous, no
|
|
93
|
+
* I/O. Returns { ok, violations: string[] }. */
|
|
94
|
+
export function validateIndividual(ind) {
|
|
95
|
+
const violations = [];
|
|
96
|
+
checkIndividual(ind, violations);
|
|
97
|
+
if (ind?.class === "Fact") checkFact(ind, violations);
|
|
98
|
+
if (ind?.class === "Rule") checkRule(ind, violations);
|
|
99
|
+
return { ok: violations.length === 0, violations };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The ingest gate: throw a clear, aggregated error if `ind` violates the
|
|
103
|
+
* shape contract, so a malformed Fact/Rule never reaches mutateMemory's
|
|
104
|
+
* write. Synchronous (no engine/file I/O) — safe to `await` regardless (a
|
|
105
|
+
* synchronous throw inside an async caller's body still rejects that
|
|
106
|
+
* caller's promise correctly; a non-throwing sync return awaits to itself). */
|
|
107
|
+
export function assertIndividualValid(ind) {
|
|
108
|
+
const r = validateIndividual(ind);
|
|
109
|
+
if (!r.ok) {
|
|
110
|
+
const e = new Error(`SHACL validation failed for ${ind?.class} "${ind?.id}": ${r.violations.join(" | ")}`);
|
|
111
|
+
e.violations = r.violations;
|
|
112
|
+
throw e;
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/prose.mjs
CHANGED
|
@@ -26,7 +26,11 @@
|
|
|
26
26
|
// individual's attributes. Both are derived from the identical token set, so they can never
|
|
27
27
|
// disagree; (b) is just (a) inverted once, cheaply, at build time.
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// Exported (as of PLAN_COMPLETIONS.md Stage 0) so src/completions/group.mjs can filter
|
|
30
|
+
// splitIdentifierWords' output (which, unlike tokenizeProse, does NOT apply this list — it's
|
|
31
|
+
// built for code identifiers, where stopword-shaped fragments are rare) down to real content
|
|
32
|
+
// words before using it as a text-clustering similarity signal.
|
|
33
|
+
export const STOPWORDS = new Set(
|
|
30
34
|
("a an and or but the of to in on at for with from by as is are was were be been being " +
|
|
31
35
|
"it its this that these those i you he she they we me my your our do does did not no " +
|
|
32
36
|
"yes if then else than so such can will would should could may might about into over " +
|
package/src/syllogise.mjs
CHANGED
|
Binary file
|