@polycode-projects/the-mechanical-code-talker 1.4.0 → 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.
@@ -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
- function recomputeFactTrust(payload, fact, nowMs = Date.now()) {
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), { now: nowMs });
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
- function syncFactSources(payload, fact, nowMs = Date.now()) {
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. Returns { id }. */
689
- export async function appendFact(dir, { subject, predicate, object, provenance = "", createdAt = "", quantifier = "" } = {}) {
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
- upsertIndividual(payload, {
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
- for (const id of touched) syncFactSources(payload, byId.get(id));
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. Returns { id }. */
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
- upsertIndividual(payload, {
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
- const STOPWORDS = new Set(
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