@polycode-projects/the-mechanical-code-talker 2.6.1 → 2.7.0

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.
Files changed (56) hide show
  1. package/corpus/LICENSES.json +14 -0
  2. package/corpus/child/LICENSE-NOTICE +43 -0
  3. package/corpus/child/README.md +79 -0
  4. package/corpus/child/index.json.gz +0 -0
  5. package/corpus/child/manifest.json +313 -0
  6. package/corpus/child/shards/child-00.jsonl.gz +0 -0
  7. package/corpus/child/shards/child-01.jsonl.gz +0 -0
  8. package/corpus/child/shards/child-02.jsonl.gz +0 -0
  9. package/corpus/child/shards/child-03.jsonl.gz +0 -0
  10. package/corpus/child/shards/child-04.jsonl.gz +0 -0
  11. package/corpus/child/shards/child-05.jsonl.gz +0 -0
  12. package/corpus/child/shards/child-06.jsonl.gz +0 -0
  13. package/corpus/child/shards/child-07.jsonl.gz +0 -0
  14. package/corpus/child/shards/child-08.jsonl.gz +0 -0
  15. package/corpus/child/shards/child-09.jsonl.gz +0 -0
  16. package/corpus/child/shards/child-0a.jsonl.gz +0 -0
  17. package/corpus/child/shards/child-0b.jsonl.gz +0 -0
  18. package/corpus/child/shards/child-0c.jsonl.gz +0 -0
  19. package/corpus/child/shards/child-0d.jsonl.gz +0 -0
  20. package/corpus/child/shards/child-0e.jsonl.gz +0 -0
  21. package/corpus/child/shards/child-0f.jsonl.gz +0 -0
  22. package/corpus/child/shards/child-10.jsonl.gz +0 -0
  23. package/corpus/child/shards/child-11.jsonl.gz +0 -0
  24. package/corpus/child/shards/child-12.jsonl.gz +0 -0
  25. package/corpus/child/shards/child-13.jsonl.gz +0 -0
  26. package/corpus/child/shards/child-14.jsonl.gz +0 -0
  27. package/corpus/child/shards/child-15.jsonl.gz +0 -0
  28. package/corpus/child/shards/child-16.jsonl.gz +0 -0
  29. package/corpus/child/shards/child-17.jsonl.gz +0 -0
  30. package/corpus/child/shards/child-18.jsonl.gz +0 -0
  31. package/corpus/child/shards/child-19.jsonl.gz +0 -0
  32. package/corpus/child/shards/child-1a.jsonl.gz +0 -0
  33. package/corpus/child/shards/child-1b.jsonl.gz +0 -0
  34. package/corpus/child/shards/child-1c.jsonl.gz +0 -0
  35. package/corpus/child/shards/child-1d.jsonl.gz +0 -0
  36. package/corpus/child/shards/child-1e.jsonl.gz +0 -0
  37. package/corpus/child/shards/child-1f.jsonl.gz +0 -0
  38. package/corpus/conceptnet/child-seed.mjs +169 -0
  39. package/corpus/conceptnet/filter-dump.mjs +69 -48
  40. package/corpus/worlds/README.md +26 -0
  41. package/corpus/worlds/index.json.gz +0 -0
  42. package/corpus/worlds/manifest.json +33 -0
  43. package/corpus/worlds/shards/ashcombe-hall.jsonl.gz +0 -0
  44. package/corpus/worlds/src/ashcombe-hall.jsonl +64 -0
  45. package/package.json +4 -1
  46. package/src/adapters/corpus/child-pack.mjs +115 -0
  47. package/src/adapters/corpus/conceptnet-map.toml +7 -0
  48. package/src/adapters/corpus/worlds-pack.mjs +122 -0
  49. package/src/domain/child-pack.mjs +79 -0
  50. package/src/domain/grammar/ace.mjs +77 -0
  51. package/src/domain/grammar/lexicon-core.json +6 -0
  52. package/src/domain/memory/trust.mjs +8 -0
  53. package/src/domain/worlds-pack.mjs +71 -0
  54. package/src/services/adventure.mjs +669 -0
  55. package/src/services/chat.mjs +26 -0
  56. package/src/surfaces/web/memory-ask-browser.bundle.js +521 -39
@@ -0,0 +1,122 @@
1
+ // corpus/worlds-pack.mjs — lazy, failure-tolerated loader for the shipped
2
+ // worlds pack (corpus/worlds/): a gzipped world index consulted first, then
3
+ // exactly one gzipped JSONL shard per world. Nothing here ever throws at a
4
+ // caller — an absent, truncated or corrupt pack reads as null, and a null is
5
+ // the ordinary honest decline ("no worlds pack here").
6
+ //
7
+ // The provider seam mirrors reference-pack.mjs's exactly:
8
+ // registerWorldsPackProvider swaps the whole lookup behind one async
9
+ // `{ list(), load(worldName) }` contract. It exists because a browser surface
10
+ // cannot read this filesystem layout — a web provider can fetch worlds
11
+ // instead, and chat code never knows which one it is talking to. No provider
12
+ // registered = the fs loader below.
13
+
14
+ import { readFileSync } from "node:fs";
15
+ import { gunzipSync } from "node:zlib";
16
+ import { fileURLToPath } from "node:url";
17
+ import { dirname, join } from "node:path";
18
+ import { isWorldsIndexEntry, isWorldRow, isWorldFactRow, isWorldRuleRow, isWorldMetaRow } from "../../domain/worlds-pack.mjs";
19
+
20
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
21
+
22
+ /** The pack directory: TMCT_WORLDS_PACK_DIR when set, else the package's own
23
+ * corpus/worlds/. */
24
+ export function worldsPackDir(env = process.env) {
25
+ return env?.TMCT_WORLDS_PACK_DIR || join(PKG_ROOT, "corpus", "worlds");
26
+ }
27
+
28
+ const indexCacheByDir = new Map(); // dir -> { worldName: {s} } | null
29
+ const worldCacheByKey = new Map(); // `${dir}\0${world}` -> payload | null
30
+
31
+ /** Drop every cached index/world — for tests that mutate a pack dir. */
32
+ export function clearWorldsPackCache() {
33
+ indexCacheByDir.clear();
34
+ worldCacheByKey.clear();
35
+ }
36
+
37
+ function readGunzipped(file) {
38
+ try {
39
+ return gunzipSync(readFileSync(file));
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /** The pack's world index, lazily read and cached per dir; null (cached)
46
+ * when the pack is absent or unreadable. Never throws. */
47
+ export function loadWorldsIndex(dir) {
48
+ if (indexCacheByDir.has(dir)) return indexCacheByDir.get(dir);
49
+ let index = null;
50
+ const body = readGunzipped(join(dir, "index.json.gz"));
51
+ if (body) {
52
+ try {
53
+ const parsed = JSON.parse(body.toString("utf8"));
54
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) index = parsed;
55
+ } catch { /* tolerated: a corrupt index is an absent pack */ }
56
+ }
57
+ indexCacheByDir.set(dir, index);
58
+ return index;
59
+ }
60
+
61
+ /** One world by name: index hit -> the one shard the index names (cached) ->
62
+ * { facts, rules, meta }. Null on an index miss or an unreadable shard, so
63
+ * an unknown world never costs a shard read. Never throws. */
64
+ export function loadWorld(dir, worldName) {
65
+ const index = loadWorldsIndex(dir);
66
+ if (!index) return null;
67
+ const entry = index[String(worldName ?? "")];
68
+ if (!isWorldsIndexEntry(entry)) return null;
69
+ const key = `${dir}\0${worldName}`;
70
+ if (worldCacheByKey.has(key)) return worldCacheByKey.get(key);
71
+ let payload = null;
72
+ const body = readGunzipped(join(dir, "shards", `${entry.s}.jsonl.gz`));
73
+ if (body) {
74
+ const facts = [];
75
+ const rules = [];
76
+ let meta = null;
77
+ for (const line of body.toString("utf8").split("\n")) {
78
+ if (!line.trim()) continue;
79
+ try {
80
+ const row = JSON.parse(line);
81
+ if (!isWorldRow(row) || row.world !== worldName) continue;
82
+ if (isWorldFactRow(row)) facts.push(row);
83
+ else if (isWorldRuleRow(row)) rules.push(row);
84
+ else if (isWorldMetaRow(row) && !meta) meta = row;
85
+ } catch { /* tolerated: a bad line loses one row, not the world */ }
86
+ }
87
+ if (facts.length || rules.length || meta) payload = { name: worldName, facts, rules, meta };
88
+ }
89
+ worldCacheByKey.set(key, payload);
90
+ return payload;
91
+ }
92
+
93
+ const fsProviderFor = (dirOf) => ({
94
+ list: async () => {
95
+ const index = loadWorldsIndex(dirOf());
96
+ return index ? Object.keys(index).sort() : null;
97
+ },
98
+ load: async (worldName) => loadWorld(dirOf(), worldName),
99
+ });
100
+
101
+ const fsProvider = fsProviderFor(() => worldsPackDir());
102
+
103
+ let registeredProvider = null;
104
+
105
+ /** Swap the pack lookup: provider = { list: async () => string[]|null,
106
+ * load: async (worldName) => payload|null }. Pass null to restore the
107
+ * default fs loader. */
108
+ export function registerWorldsPackProvider(provider) {
109
+ registeredProvider = provider
110
+ && typeof provider.load === "function" && typeof provider.list === "function"
111
+ ? provider : null;
112
+ }
113
+
114
+ /** The active provider — the registered one, else the lazy fs loader. An
115
+ * explicit `env` bag (a chat turn's own env, which may carry
116
+ * TMCT_WORLDS_PACK_DIR) makes the fs loader resolve the pack dir from that
117
+ * bag instead of process.env; with no argument the behavior is unchanged. */
118
+ export function getWorldsPackProvider(env) {
119
+ if (registeredProvider) return registeredProvider;
120
+ if (env === undefined) return fsProvider;
121
+ return fsProviderFor(() => worldsPackDir(env));
122
+ }
@@ -0,0 +1,79 @@
1
+ // child-pack.mjs — the pure half of the shipped CHILD triples pack
2
+ // (corpus/child/): the shard naming contract, the index/row shape validators
3
+ // every writer and reader share, and the provenance tag a fact learned from the
4
+ // pack carries. The pack itself is gzipped JSONL shards plus a gzipped term
5
+ // index; loading them is I/O and lives in src/adapters/corpus/child-pack.mjs.
6
+ //
7
+ // The pack is a lazy learn-on-miss reference the clean-miss cascade consults:
8
+ // on a miss for term T the loader returns T's ConceptNet triples, and the chat
9
+ // hook appends them to memory (see childProvenanceTag). It mirrors the reference
10
+ // pack's on-disk shape (index -> one shard per hit) but carries TRIPLES, not
11
+ // article prose — so its row is a list of {subject, predicate, object} facts,
12
+ // already mapped through conceptnet-map.toml into tmct's predicate vocabulary.
13
+
14
+ import { fnv1aHex, normFactTerm } from "./hash.mjs";
15
+
16
+ export const CHILD_PACK_NAME = "conceptnet";
17
+ export const CHILD_SHARD_COUNT = 32;
18
+
19
+ /** The shard a term's triples row lives in: FNV-1a first byte mod 32, as the
20
+ * file basename "child-00" … "child-1f". Part of the pack's on-disk contract —
21
+ * the build script shards with THIS function, so the reader never scans. The
22
+ * term is normFactTerm-folded first, so the key the index stores and the key a
23
+ * clean-miss lookup computes are the same spelling. */
24
+ export function shardNameFor(term) {
25
+ const byte = parseInt(fnv1aHex(normFactTerm(term)).slice(0, 2), 16);
26
+ return `child-${(byte % CHILD_SHARD_COUNT).toString(16).padStart(2, "0")}`;
27
+ }
28
+
29
+ const SHARD_NAME_RE = /^child-[0-1][0-9a-f]$/;
30
+
31
+ /** An index entry {s, t, n}: the shard holding the row, the row's canonical
32
+ * term key (a normFactTerm fixed point), and the fact count (a positive
33
+ * integer, so a zero-fact row can never be indexed). */
34
+ export function isChildIndexEntry(e) {
35
+ return !!e && typeof e === "object"
36
+ && typeof e.s === "string" && SHARD_NAME_RE.test(e.s)
37
+ && typeof e.t === "string" && e.t.length > 0
38
+ && Number.isInteger(e.n) && e.n > 0;
39
+ }
40
+
41
+ /** One triple in a row: {subject, predicate, object, weight?}. subject/object
42
+ * are human terms ("penguin", "bird"); predicate is a tmct vocabulary URI
43
+ * ("rdfs:subClassOf", "mgx:capableOf", "mgxneg:capableOf"). weight, when
44
+ * present, is the ConceptNet edge weight (a positive number). */
45
+ export function isChildFact(f) {
46
+ if (!f || typeof f !== "object") return false;
47
+ for (const field of ["subject", "predicate", "object"]) {
48
+ if (typeof f[field] !== "string" || !f[field]) return false;
49
+ }
50
+ if (f.weight !== undefined && !(typeof f.weight === "number" && Number.isFinite(f.weight) && f.weight > 0)) return false;
51
+ return true;
52
+ }
53
+
54
+ /** A shard row: {term, facts: [ChildFact, …]} — the term's edges, at least one,
55
+ * every one a valid ChildFact that actually touches the term (as subject or
56
+ * object, once normalised). */
57
+ export function isChildFactsRow(row) {
58
+ if (!row || typeof row !== "object") return false;
59
+ if (typeof row.term !== "string" || !row.term) return false;
60
+ if (!Array.isArray(row.facts) || row.facts.length === 0) return false;
61
+ for (const f of row.facts) {
62
+ if (!isChildFact(f)) return false;
63
+ if (normFactTerm(f.subject) !== row.term && normFactTerm(f.object) !== row.term) return false;
64
+ }
65
+ return true;
66
+ }
67
+
68
+ /** The provenance tag a fact stored from a child-pack lookup carries. The chat
69
+ * hook stamps every fact it appends from term T's row with THIS tag;
70
+ * memory/trust.mjs parses it back to a corpus-tier Source ({kind:"corpus",
71
+ * name:"conceptnet"}) — the child slice is curated ConceptNet, scored at the
72
+ * 0.7 corpus prior. The term segment records which miss pulled the fact in, so
73
+ * a fact's origin shard stays auditable on its factProvenance string even
74
+ * though the Source it corroborates is the shared ConceptNet corpus. */
75
+ export function childProvenanceTag(term) {
76
+ return `${CHILD_PROVENANCE_PREFIX}${CHILD_PACK_NAME}:${normFactTerm(term)}`;
77
+ }
78
+
79
+ export const CHILD_PROVENANCE_PREFIX = "child:";
@@ -457,6 +457,83 @@ export function parseAce(sentence, lexicon = loadLexicon()) {
457
457
  return parseRelation(lexicon, toks, lower);
458
458
  }
459
459
 
460
+ // ---- the imperative command pattern -----------------------------------------
461
+ // A subjectless action command ("go north", "take the key", "unlock the
462
+ // cabinet with the key"). Unlike the nine assertion/question patterns above,
463
+ // this one produces no OWL triple — an imperative has no truth value to
464
+ // assert, it has an ACTION NAME to resolve against the taught action
465
+ // families — so parseImperative returns a structured command instead
466
+ // (precedent: parseCardinality's own non-triple `n`). It is a separate
467
+ // export, never folded into parseAce: every triple pattern requires an
468
+ // explicit subject noun phrase, and parseAce's callers expect triples.
469
+ //
470
+ // The verb set is CLOSED (an unlisted verb is a hard null, never a guess),
471
+ // and object phrases resolve through the same lexicon-noun gate as every
472
+ // other pattern: a structural fit over an undeclared word rides out as
473
+ // `residue` so the caller can name it; a declared word in an unusable shape
474
+ // is a hard null.
475
+
476
+ const IMPERATIVE_VERBS = new Set(["go", "take", "drop", "open", "unlock", "close", "give", "look"]);
477
+ const IMPERATIVE_DIRECTIONS = new Set(["north", "south", "east", "west", "up", "down"]);
478
+
479
+ /** Resolve one imperative object phrase to its bare lexicon term. */
480
+ function imperativeNP(lexicon, tokens) {
481
+ const np = resolveNP(lexicon, tokens);
482
+ if (np.term == null) return { term: null, unknown: np.unknown };
483
+ return { term: local(lexicon, np.term), unknown: [] };
484
+ }
485
+
486
+ /**
487
+ * Parse one imperative command against the closed verb set. Returns
488
+ * `{ pattern: "imperative", verb, residue, object?, indirectObject?,
489
+ * instrument?, direction? }`, a residue-carrying miss for a structural fit
490
+ * over undeclared words (`residue` non-empty, no slots), or null when the
491
+ * sentence is not an imperative of this fragment at all.
492
+ */
493
+ export function parseImperative(sentence, lexicon = loadLexicon()) {
494
+ const toks = tokenize(sentence);
495
+ if (!toks.length) return null;
496
+ const verb = toks[0].toLowerCase();
497
+ if (!IMPERATIVE_VERBS.has(verb)) return null;
498
+ const rest = toks.slice(1);
499
+ const lower = rest.map((t) => t.toLowerCase());
500
+ const command = (fields) => ({ pattern: "imperative", verb, residue: [], ...fields });
501
+ const miss = (unknown) => (unknown.length ? { pattern: "imperative", verb, residue: unknown } : null);
502
+
503
+ if (verb === "look") {
504
+ if (!rest.length || (rest.length === 1 && lower[0] === "around")) return command({});
505
+ return null;
506
+ }
507
+ if (verb === "go") {
508
+ if (rest.length === 1 && IMPERATIVE_DIRECTIONS.has(lower[0])) return command({ direction: lower[0] });
509
+ return null;
510
+ }
511
+ if (verb === "give") {
512
+ const toIdx = lower.indexOf("to");
513
+ if (toIdx < 1 || toIdx === rest.length - 1) return null;
514
+ const object = imperativeNP(lexicon, rest.slice(0, toIdx));
515
+ const indirect = imperativeNP(lexicon, rest.slice(toIdx + 1));
516
+ if (object.term == null || indirect.term == null) return miss([...object.unknown, ...indirect.unknown]);
517
+ return command({ object: object.term, indirectObject: indirect.term });
518
+ }
519
+ if (verb === "unlock") {
520
+ const withIdx = lower.indexOf("with");
521
+ if (withIdx !== -1) {
522
+ if (withIdx < 1 || withIdx === rest.length - 1) return null;
523
+ const object = imperativeNP(lexicon, rest.slice(0, withIdx));
524
+ const instrument = imperativeNP(lexicon, rest.slice(withIdx + 1));
525
+ if (object.term == null || instrument.term == null) return miss([...object.unknown, ...instrument.unknown]);
526
+ return command({ object: object.term, instrument: instrument.term });
527
+ }
528
+ // fall through to the plain-object arm: "unlock the cabinet" is a valid
529
+ // command whose missing instrument is the CALLER's precondition to name.
530
+ }
531
+ if (!rest.length) return null;
532
+ const object = imperativeNP(lexicon, rest);
533
+ if (object.term == null) return miss(object.unknown);
534
+ return command({ object: object.term });
535
+ }
536
+
460
537
  /** Pattern 9 — "N can VERB" → mgx:capableOf. The modal is not a relation
461
538
  * verb: without this, parseRelation reads "can" through lookupVerb and
462
539
  * asserts a generic object property ("dog cans swim") that no capability
@@ -274,6 +274,9 @@
274
274
  "judge": {},
275
275
  "priest": {},
276
276
  "servant": {},
277
+ "butler": {},
278
+ "housekeeper": {},
279
+ "gardener": {},
277
280
  "employee": {},
278
281
  "boss": {},
279
282
  "husband": {},
@@ -328,6 +331,9 @@
328
331
  "dress": {},
329
332
  "clothing": {},
330
333
  "chair": {},
334
+ "desk": {},
335
+ "lamp": {},
336
+ "portrait": {},
331
337
  "bed": {},
332
338
  "door": {},
333
339
  "window": {},
@@ -30,6 +30,11 @@ function parseChatTagRest(rest) {
30
30
  * kind set (the kinds SOURCE_PRIOR scores):
31
31
  * corpus:conceptnet /r/IsA -> { kind:"corpus", name:"conceptnet" }
32
32
  * corpus-weak:conceptnet /r/RelatedTo -> { kind:"corpusWeak", name:"conceptnet" }
33
+ * child:conceptnet:<term> -> { kind:"corpus", name:"conceptnet" }
34
+ * (the lazy child triples pack is curated ConceptNet — same corpus tier,
35
+ * same 0.7 prior, same shared Source as the bulk conceptnet import; the
36
+ * <term> segment records which miss pulled the fact in and is not part of
37
+ * the Source identity)
33
38
  * ace:chat:<session>@<ts> -> { kind:"operator", createdAt:<ts>, sessionId:<session> }
34
39
  * teach:chat:<session>@<ts> -> { kind:"teach", createdAt:<ts>, sessionId:<session> }
35
40
  * web:<url> | url:<url> -> { kind:"web", url:<url> }
@@ -54,6 +59,9 @@ export function provenanceTagToSource(tag) {
54
59
  const head = t.split(/\s+/)[0]; // drop trailing " /r/IsA" etc.
55
60
  if (head.startsWith("corpus-weak:")) return { kind: "corpusWeak", name: head.slice("corpus-weak:".length) || "unknown" };
56
61
  if (head.startsWith("corpus:")) return { kind: "corpus", name: head.slice("corpus:".length) || "unknown" };
62
+ // child:<pack>:<term> — the lazy child triples pack, scored at the corpus tier
63
+ // under the pack's shared Source; the per-term tail is dropped from the id.
64
+ if (head.startsWith("child:")) return { kind: "corpus", name: head.slice("child:".length).split(":")[0] || "unknown" };
57
65
  if (head.startsWith("ace:")) return { kind: "operator", ...parseChatTagRest(head.slice("ace:".length)) };
58
66
  if (head.startsWith("teach:")) {
59
67
  // the chat teach lane's natural frames — chat.mjs's teachProvenanceTag
@@ -0,0 +1,71 @@
1
+ // worlds-pack.mjs — the pure half of the shipped worlds pack: the row-shape
2
+ // validators every writer and reader share, the provenance tag a loaded
3
+ // world's facts carry, and the closed rule-kind set a world may instate. The
4
+ // pack itself is one gzipped JSONL shard per world plus a gzipped world
5
+ // index; loading them is I/O and lives in src/adapters/corpus/worlds-pack.mjs.
6
+ //
7
+ // A world row is one of three kinds:
8
+ // fact — an ordinary graph triple the loader appends into the session's
9
+ // memory store (rooms, exits, placements, NPC cast);
10
+ // rule — a pre-built action-Rule row (the same four action kinds the live
11
+ // teach frames store) the loader instates via appendRule;
12
+ // meta — the world's one announcement row (the opening line).
13
+
14
+ const WORLD_NAME_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
15
+
16
+ /** The action-rule kinds a world shard may carry — the same closed set
17
+ * src/adapters/memory/core.mjs stores for live-taught actions. */
18
+ export const WORLD_RULE_KINDS = Object.freeze([
19
+ "action-signature", "action-precond", "action-effect", "action-constraint",
20
+ ]);
21
+
22
+ const RULE_KIND_SET = new Set(WORLD_RULE_KINDS);
23
+
24
+ const isNonEmptyString = (v) => typeof v === "string" && v.trim() !== "";
25
+
26
+ /** A pack world name: lowercase, hyphen-joined ("ashcombe-hall"). */
27
+ export function isWorldName(name) {
28
+ return typeof name === "string" && WORLD_NAME_RE.test(name);
29
+ }
30
+
31
+ /** An index entry { s }: the shard (basename, no extension) holding the
32
+ * world's rows. */
33
+ export function isWorldsIndexEntry(e) {
34
+ return !!e && typeof e === "object" && isNonEmptyString(e.s);
35
+ }
36
+
37
+ /** A fact row: { world, kind:"fact", subject, predicate, object }. */
38
+ export function isWorldFactRow(row) {
39
+ return !!row && typeof row === "object" && row.kind === "fact"
40
+ && isWorldName(row.world)
41
+ && isNonEmptyString(row.subject) && isNonEmptyString(row.predicate) && isNonEmptyString(row.object);
42
+ }
43
+
44
+ /** A rule row: { world, kind:"rule", name, ruleKind, slots } — ruleKind one
45
+ * of WORLD_RULE_KINDS, slots a flat object of non-empty strings (the exact
46
+ * per-kind slot contract is appendRule's to enforce at instate time). */
47
+ export function isWorldRuleRow(row) {
48
+ if (!row || typeof row !== "object" || row.kind !== "rule") return false;
49
+ if (!isWorldName(row.world) || !isNonEmptyString(row.name)) return false;
50
+ if (!RULE_KIND_SET.has(row.ruleKind)) return false;
51
+ if (!row.slots || typeof row.slots !== "object" || Array.isArray(row.slots)) return false;
52
+ const values = Object.values(row.slots);
53
+ return values.length > 0 && values.every(isNonEmptyString);
54
+ }
55
+
56
+ /** A meta row: { world, kind:"meta", opening } — the world's opening line. */
57
+ export function isWorldMetaRow(row) {
58
+ return !!row && typeof row === "object" && row.kind === "meta"
59
+ && isWorldName(row.world) && isNonEmptyString(row.opening);
60
+ }
61
+
62
+ /** Any valid world row. */
63
+ export function isWorldRow(row) {
64
+ return isWorldFactRow(row) || isWorldRuleRow(row) || isWorldMetaRow(row);
65
+ }
66
+
67
+ /** The provenance tag every fact/rule loaded from a world carries —
68
+ * "world:<name>", so a loaded world is auditable apart from taught facts. */
69
+ export function worldProvenanceTag(worldName) {
70
+ return `world:${worldName}`;
71
+ }