@patterkit/runtime 0.1.0 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -7,7 +7,16 @@ version number always means the same runtime behaviour. This package is versione
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.1.0] - Unreleased
10
+ ## [0.2.0] - 2026-07-07
11
+
12
+ ### Added
13
+ - **Best match** selection (a new `sequence` order, `specificity`): among the eligible children,
14
+ play the one whose condition most specifically fits the current state; equally-specific ties break
15
+ by the seeded shuffle, and a condition-less child is the filler that wins only when nothing more
16
+ specific applies. Composes with the exhaust axis (re-pickable, or graceful degradation to the
17
+ filler). Locked by the conformance corpus, so all four runtimes agree.
18
+
19
+ ## [0.1.0] - 2026-07-04
11
20
 
12
21
  ### Added
13
22
  - The Patter runtime in JS/TS: `Engine` + `Flow` over a compiled `.patterc` bundle - scenes,
package/dist/index.cjs CHANGED
@@ -933,7 +933,7 @@ var Flow = class {
933
933
  case "sequence": {
934
934
  const order = group.options?.order ?? "sequential";
935
935
  const exhaust = group.options?.exhaust ?? "once";
936
- return order === "shuffle" ? this.pickShuffle(eligible, exhaust, st) : this.pickSequential(eligible, exhaust, st);
936
+ return order === "shuffle" ? this.pickShuffle(eligible, exhaust, st) : order === "specificity" ? this.pickSpecificity(eligible, exhaust, st) : this.pickSequential(eligible, exhaust, st);
937
937
  }
938
938
  case "run":
939
939
  case "choice":
@@ -980,6 +980,78 @@ var Flow = class {
980
980
  st.last = id;
981
981
  return eligible.find((c) => c.id === id);
982
982
  }
983
+ /**
984
+ * `sequence` with `order: "specificity"` - **Best match**: score every eligible child by how
985
+ * specifically its condition fits the CURRENT state (`matchedSpec`), keep the top-scoring tier,
986
+ * and break ties with the seeded shuffle (no immediate repeat). A child with no condition scores
987
+ * 0, so it is the filler that wins only when nothing more specific is eligible.
988
+ *
989
+ * `exhaust` composes as it does for the other orders: `repeat` re-scores the full eligible set
990
+ * every draw (re-pickable - the character keeps preferring the on-topic line); `once` uses each
991
+ * pick up (a bag of remaining ids), so as specific lines are consumed the group slides down to
992
+ * less-specific ones and finally the filler, then yields null; `stick` degrades like `once` but
993
+ * holds the final pick forever instead of drying up.
994
+ */
995
+ pickSpecificity(eligible, exhaust, st) {
996
+ let pool = eligible;
997
+ if (exhaust !== "repeat") {
998
+ if (st.bag === void 0) st.bag = eligible.map((c) => c.id);
999
+ const remaining = new Set(st.bag);
1000
+ pool = eligible.filter((c) => remaining.has(c.id));
1001
+ if (pool.length === 0) {
1002
+ return exhaust === "stick" && st.last !== void 0 ? eligible.find((c) => c.id === st.last) ?? null : null;
1003
+ }
1004
+ }
1005
+ let best = -1;
1006
+ const scored = pool.map((c) => {
1007
+ const s = this.specScore(c);
1008
+ if (s > best) best = s;
1009
+ return { c, s };
1010
+ });
1011
+ const tier = scored.filter((x) => x.s === best).map((x) => x.c);
1012
+ let pick;
1013
+ if (tier.length === 1) {
1014
+ pick = tier[0];
1015
+ } else {
1016
+ const p = st.last !== void 0 ? tier.findIndex((c) => c.id === st.last) : -1;
1017
+ let i = Math.floor(this.rng() * (p >= 0 ? tier.length - 1 : tier.length));
1018
+ if (p >= 0 && i >= p) i++;
1019
+ pick = tier[i];
1020
+ }
1021
+ if (exhaust !== "repeat") st.bag = st.bag.filter((id) => id !== pick.id);
1022
+ st.last = pick.id;
1023
+ return pick;
1024
+ }
1025
+ /** A child's Best-match score against the current state: 0 when it has no condition (the filler
1026
+ * tier), else the specificity of its (already-passing) condition. */
1027
+ specScore(node) {
1028
+ return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true) : 0;
1029
+ }
1030
+ /**
1031
+ * The **matched-specificity** metric (parity contract): how many atomic constraints are actively
1032
+ * holding this condition TRUE against the live state. Evaluation-aware, not a static clause count -
1033
+ * it walks the tree with a De-Morgan polarity flag so `or` and `not` score the branch that is
1034
+ * actually carrying the truth. `want` = "does this subtree need to be true for the whole condition
1035
+ * to hold?" (true at the root). Only `and`/`or`/`not`/`check_flags` are structural; every other
1036
+ * node (comparisons, scoped vars, literals, other calls) is an atom, evaluated whole.
1037
+ */
1038
+ matchedSpec(node, want) {
1039
+ if (node.kind === "binary" && (node.op === "and" || node.op === "or")) {
1040
+ const behaveAsAnd = node.op === "and" === want;
1041
+ const l = this.matchedSpec(node.left, want);
1042
+ const r = this.matchedSpec(node.right, want);
1043
+ return behaveAsAnd ? l > 0 && r > 0 ? l + r : 0 : Math.max(l, r);
1044
+ }
1045
+ if (node.kind === "unary" && node.op === "not") {
1046
+ return this.matchedSpec(node.operand, !want);
1047
+ }
1048
+ if (node.kind === "call" && node.name === "check_flags") {
1049
+ const operands = Math.max(1, node.args.length - 1);
1050
+ const hit = truthy((0, import_expr.evaluate)(node, this.evalCtx, import_dialect.patterDialect));
1051
+ return want ? hit ? operands : 0 : hit ? 0 : 1;
1052
+ }
1053
+ return truthy((0, import_expr.evaluate)(node, this.evalCtx, import_dialect.patterDialect)) === want ? 1 : 0;
1054
+ }
983
1055
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
984
1056
  selectorState(group) {
985
1057
  const map = group.shared ? this.host.sharedSelectors : this.selectors;
@@ -1001,12 +1073,17 @@ var Flow = class {
1001
1073
  return truthy(this.evalExpr(node.condition));
1002
1074
  }
1003
1075
  evalExpr(expr) {
1076
+ return (0, import_expr.evaluate)(this.conditionAst(expr), this.evalCtx, import_dialect.patterDialect);
1077
+ }
1078
+ /** The deserialised (in-memory) AST for an expression, cached per Expression. Shared by the
1079
+ * evaluator and the Best-match specificity walker so both work off one parse. */
1080
+ conditionAst(expr) {
1004
1081
  let ast = astCache.get(expr);
1005
1082
  if (!ast) {
1006
1083
  ast = (0, import_expr.deserialiseAst)(expr.ast);
1007
1084
  astCache.set(expr, ast);
1008
1085
  }
1009
- return (0, import_expr.evaluate)(ast, this.evalCtx, import_dialect.patterDialect);
1086
+ return ast;
1010
1087
  }
1011
1088
  /** Record an entry of a node (entered-only; spec §7): bumps the flow + world counts. */
1012
1089
  enter(id) {