@patterkit/runtime 0.1.0 → 0.2.1

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/dist/index.d.cts CHANGED
@@ -550,11 +550,39 @@ declare class Flow {
550
550
  * permanent terminal; `once` stops after one pass; `repeat` reshuffles.
551
551
  */
552
552
  private pickShuffle;
553
+ /**
554
+ * `sequence` with `order: "specificity"` - **Best match**: score every eligible child by how
555
+ * specifically its condition fits the CURRENT state (`matchedSpec`), keep the top-scoring tier,
556
+ * and break ties with the seeded shuffle (no immediate repeat). A child with no condition scores
557
+ * 0, so it is the filler that wins only when nothing more specific is eligible.
558
+ *
559
+ * `exhaust` composes as it does for the other orders: `repeat` re-scores the full eligible set
560
+ * every draw (re-pickable - the character keeps preferring the on-topic line); `once` uses each
561
+ * pick up (a bag of remaining ids), so as specific lines are consumed the group slides down to
562
+ * less-specific ones and finally the filler, then yields null; `stick` degrades like `once` but
563
+ * holds the final pick forever instead of drying up.
564
+ */
565
+ private pickSpecificity;
566
+ /** A child's Best-match score against the current state: 0 when it has no condition (the filler
567
+ * tier), else the specificity of its (already-passing) condition. */
568
+ private specScore;
569
+ /**
570
+ * The **matched-specificity** metric (parity contract): how many atomic constraints are actively
571
+ * holding this condition TRUE against the live state. Evaluation-aware, not a static clause count -
572
+ * it walks the tree with a De-Morgan polarity flag so `or` and `not` score the branch that is
573
+ * actually carrying the truth. `want` = "does this subtree need to be true for the whole condition
574
+ * to hold?" (true at the root). Only `and`/`or`/`not`/`check_flags` are structural; every other
575
+ * node (comparisons, scoped vars, literals, other calls) is an atom, evaluated whole.
576
+ */
577
+ private matchedSpec;
553
578
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
554
579
  private selectorState;
555
580
  private runEffects;
556
581
  private eligible;
557
582
  private evalExpr;
583
+ /** The deserialised (in-memory) AST for an expression, cached per Expression. Shared by the
584
+ * evaluator and the Best-match specificity walker so both work off one parse. */
585
+ private conditionAst;
558
586
  /** Record an entry of a node (entered-only; spec §7): bumps the flow + world counts. */
559
587
  private enter;
560
588
  /** Next float in [0, 1): the shared custom PRNG, or this flow's serialisable mulberry32. */
package/dist/index.d.ts CHANGED
@@ -550,11 +550,39 @@ declare class Flow {
550
550
  * permanent terminal; `once` stops after one pass; `repeat` reshuffles.
551
551
  */
552
552
  private pickShuffle;
553
+ /**
554
+ * `sequence` with `order: "specificity"` - **Best match**: score every eligible child by how
555
+ * specifically its condition fits the CURRENT state (`matchedSpec`), keep the top-scoring tier,
556
+ * and break ties with the seeded shuffle (no immediate repeat). A child with no condition scores
557
+ * 0, so it is the filler that wins only when nothing more specific is eligible.
558
+ *
559
+ * `exhaust` composes as it does for the other orders: `repeat` re-scores the full eligible set
560
+ * every draw (re-pickable - the character keeps preferring the on-topic line); `once` uses each
561
+ * pick up (a bag of remaining ids), so as specific lines are consumed the group slides down to
562
+ * less-specific ones and finally the filler, then yields null; `stick` degrades like `once` but
563
+ * holds the final pick forever instead of drying up.
564
+ */
565
+ private pickSpecificity;
566
+ /** A child's Best-match score against the current state: 0 when it has no condition (the filler
567
+ * tier), else the specificity of its (already-passing) condition. */
568
+ private specScore;
569
+ /**
570
+ * The **matched-specificity** metric (parity contract): how many atomic constraints are actively
571
+ * holding this condition TRUE against the live state. Evaluation-aware, not a static clause count -
572
+ * it walks the tree with a De-Morgan polarity flag so `or` and `not` score the branch that is
573
+ * actually carrying the truth. `want` = "does this subtree need to be true for the whole condition
574
+ * to hold?" (true at the root). Only `and`/`or`/`not`/`check_flags` are structural; every other
575
+ * node (comparisons, scoped vars, literals, other calls) is an atom, evaluated whole.
576
+ */
577
+ private matchedSpec;
553
578
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
554
579
  private selectorState;
555
580
  private runEffects;
556
581
  private eligible;
557
582
  private evalExpr;
583
+ /** The deserialised (in-memory) AST for an expression, cached per Expression. Shared by the
584
+ * evaluator and the Best-match specificity walker so both work off one parse. */
585
+ private conditionAst;
558
586
  /** Record an entry of a node (entered-only; spec §7): bumps the flow + world counts. */
559
587
  private enter;
560
588
  /** Next float in [0, 1): the shared custom PRNG, or this flow's serialisable mulberry32. */
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/engine.ts
2
2
  import { evaluate, deserialiseAst } from "@wildwinter/expr";
3
+ import { matchedSpecificity as scoreSpecificity } from "@wildwinter/expr-specificity";
3
4
  import { ScopeRegistry } from "@wildwinter/scoperegistry";
4
5
  import { patterDialect, interpolate, splitRef, stripCaptions } from "@patterkit/dialect";
5
6
  import { walkNodes, effectiveGameId, castStringKey, DEFAULT_CAPTION_DELIMITERS, DEFAULT_CAPTION_CHARACTER } from "@patterkit/model";
@@ -902,7 +903,7 @@ var Flow = class {
902
903
  case "sequence": {
903
904
  const order = group.options?.order ?? "sequential";
904
905
  const exhaust = group.options?.exhaust ?? "once";
905
- return order === "shuffle" ? this.pickShuffle(eligible, exhaust, st) : this.pickSequential(eligible, exhaust, st);
906
+ return order === "shuffle" ? this.pickShuffle(eligible, exhaust, st) : order === "specificity" ? this.pickSpecificity(eligible, exhaust, st) : this.pickSequential(eligible, exhaust, st);
906
907
  }
907
908
  case "run":
908
909
  case "choice":
@@ -949,6 +950,65 @@ var Flow = class {
949
950
  st.last = id;
950
951
  return eligible.find((c) => c.id === id);
951
952
  }
953
+ /**
954
+ * `sequence` with `order: "specificity"` - **Best match**: score every eligible child by how
955
+ * specifically its condition fits the CURRENT state (`matchedSpec`), keep the top-scoring tier,
956
+ * and break ties with the seeded shuffle (no immediate repeat). A child with no condition scores
957
+ * 0, so it is the filler that wins only when nothing more specific is eligible.
958
+ *
959
+ * `exhaust` composes as it does for the other orders: `repeat` re-scores the full eligible set
960
+ * every draw (re-pickable - the character keeps preferring the on-topic line); `once` uses each
961
+ * pick up (a bag of remaining ids), so as specific lines are consumed the group slides down to
962
+ * less-specific ones and finally the filler, then yields null; `stick` degrades like `once` but
963
+ * holds the final pick forever instead of drying up.
964
+ */
965
+ pickSpecificity(eligible, exhaust, st) {
966
+ let pool = eligible;
967
+ if (exhaust !== "repeat") {
968
+ if (st.bag === void 0) st.bag = eligible.map((c) => c.id);
969
+ const remaining = new Set(st.bag);
970
+ pool = eligible.filter((c) => remaining.has(c.id));
971
+ if (pool.length === 0) {
972
+ return exhaust === "stick" && st.last !== void 0 ? eligible.find((c) => c.id === st.last) ?? null : null;
973
+ }
974
+ }
975
+ let best = -1;
976
+ const scored = pool.map((c) => {
977
+ const s = this.specScore(c);
978
+ if (s > best) best = s;
979
+ return { c, s };
980
+ });
981
+ const tier = scored.filter((x) => x.s === best).map((x) => x.c);
982
+ let pick;
983
+ if (tier.length === 1) {
984
+ pick = tier[0];
985
+ } else {
986
+ const p = st.last !== void 0 ? tier.findIndex((c) => c.id === st.last) : -1;
987
+ let i = Math.floor(this.rng() * (p >= 0 ? tier.length - 1 : tier.length));
988
+ if (p >= 0 && i >= p) i++;
989
+ pick = tier[i];
990
+ }
991
+ if (exhaust !== "repeat") st.bag = st.bag.filter((id) => id !== pick.id);
992
+ st.last = pick.id;
993
+ return pick;
994
+ }
995
+ /** A child's Best-match score against the current state: 0 when it has no condition (the filler
996
+ * tier), else the specificity of its (already-passing) condition. */
997
+ specScore(node) {
998
+ return node.condition ? this.matchedSpec(this.conditionAst(node.condition), true) : 0;
999
+ }
1000
+ /**
1001
+ * The **matched-specificity** metric (parity contract): how many atomic constraints are actively
1002
+ * holding this condition TRUE against the live state. Evaluation-aware, not a static clause count -
1003
+ * it walks the tree with a De-Morgan polarity flag so `or` and `not` score the branch that is
1004
+ * actually carrying the truth. `want` = "does this subtree need to be true for the whole condition
1005
+ * to hold?" (true at the root). Only `and`/`or`/`not`/`check_flags` are structural; every other
1006
+ * node (comparisons, scoped vars, literals, other calls) is an atom, evaluated whole.
1007
+ */
1008
+ matchedSpec(node, want) {
1009
+ const evalTruthy = (n) => truthy(evaluate(n, this.evalCtx, patterDialect));
1010
+ return scoreSpecificity(node, evalTruthy, { want });
1011
+ }
952
1012
  /** A selector's cursor state - shared across flows (`group.shared`) or this flow's own. */
953
1013
  selectorState(group) {
954
1014
  const map = group.shared ? this.host.sharedSelectors : this.selectors;
@@ -970,12 +1030,17 @@ var Flow = class {
970
1030
  return truthy(this.evalExpr(node.condition));
971
1031
  }
972
1032
  evalExpr(expr) {
1033
+ return evaluate(this.conditionAst(expr), this.evalCtx, patterDialect);
1034
+ }
1035
+ /** The deserialised (in-memory) AST for an expression, cached per Expression. Shared by the
1036
+ * evaluator and the Best-match specificity walker so both work off one parse. */
1037
+ conditionAst(expr) {
973
1038
  let ast = astCache.get(expr);
974
1039
  if (!ast) {
975
1040
  ast = deserialiseAst(expr.ast);
976
1041
  astCache.set(expr, ast);
977
1042
  }
978
- return evaluate(ast, this.evalCtx, patterDialect);
1043
+ return ast;
979
1044
  }
980
1045
  /** Record an entry of a node (entered-only; spec §7): bumps the flow + world counts. */
981
1046
  enter(id) {