@intentius/chant 0.89.0 → 0.91.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 (194) hide show
  1. package/dist/cli/handlers/components.d.ts +8 -0
  2. package/dist/cli/handlers/components.d.ts.map +1 -1
  3. package/dist/cli/handlers/operator.d.ts +14 -0
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/run.d.ts.map +1 -1
  6. package/dist/cli/handlers/serve.d.ts.map +1 -1
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/mcp/server.d.ts +10 -5
  9. package/dist/cli/mcp/server.d.ts.map +1 -1
  10. package/dist/cli/mcp/types.d.ts +13 -5
  11. package/dist/cli/mcp/types.d.ts.map +1 -1
  12. package/dist/cli/mcp/workspace-tools.d.ts +55 -0
  13. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
  14. package/dist/cli/registry.d.ts +31 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/components/verbs/vuln-scan.d.ts +72 -0
  17. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  18. package/dist/lifecycle/git.d.ts +40 -5
  19. package/dist/lifecycle/git.d.ts.map +1 -1
  20. package/dist/lifecycle/lease.d.ts +72 -16
  21. package/dist/lifecycle/lease.d.ts.map +1 -1
  22. package/dist/lifecycle/member-ledger.d.ts +3 -2
  23. package/dist/lifecycle/member-ledger.d.ts.map +1 -1
  24. package/dist/lifecycle/plan-ledger.d.ts +114 -0
  25. package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
  26. package/dist/lifecycle/work-lease.d.ts +140 -0
  27. package/dist/lifecycle/work-lease.d.ts.map +1 -0
  28. package/dist/op/activities/activity-contracts.d.ts +2 -2
  29. package/dist/op/builders.d.ts.map +1 -1
  30. package/dist/op/discover.d.ts +25 -0
  31. package/dist/op/discover.d.ts.map +1 -1
  32. package/dist/op/index.d.ts +9 -3
  33. package/dist/op/index.d.ts.map +1 -1
  34. package/dist/op/lifecycle-receipt-store.d.ts +34 -0
  35. package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
  36. package/dist/op/local-executor.d.ts +19 -0
  37. package/dist/op/local-executor.d.ts.map +1 -1
  38. package/dist/op/local-output.d.ts.map +1 -1
  39. package/dist/op/op-ir.d.ts +10 -1
  40. package/dist/op/op-ir.d.ts.map +1 -1
  41. package/dist/op/op-verb-class.d.ts.map +1 -1
  42. package/dist/op/operator.d.ts +29 -0
  43. package/dist/op/operator.d.ts.map +1 -1
  44. package/dist/op/runtime.d.ts +9 -0
  45. package/dist/op/runtime.d.ts.map +1 -1
  46. package/dist/op/runtimes/local.d.ts.map +1 -1
  47. package/dist/op/step-output-ref.d.ts +2 -2
  48. package/dist/op/step-output-ref.d.ts.map +1 -1
  49. package/dist/op/steward.d.ts +140 -0
  50. package/dist/op/steward.d.ts.map +1 -0
  51. package/dist/op/types.d.ts +51 -0
  52. package/dist/op/types.d.ts.map +1 -1
  53. package/dist/op/work-lease-decl.d.ts +18 -0
  54. package/dist/op/work-lease-decl.d.ts.map +1 -0
  55. package/dist/op/work-lease-run.d.ts +173 -0
  56. package/dist/op/work-lease-run.d.ts.map +1 -0
  57. package/dist/workspace/box-isolation.d.ts +99 -0
  58. package/dist/workspace/box-isolation.d.ts.map +1 -0
  59. package/dist/workspace/checks/box-isolation.d.ts +18 -0
  60. package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
  61. package/dist/workspace/checks/boxes.d.ts +71 -0
  62. package/dist/workspace/checks/boxes.d.ts.map +1 -0
  63. package/dist/workspace/checks/records.d.ts +1 -0
  64. package/dist/workspace/checks/records.d.ts.map +1 -1
  65. package/dist/workspace/checks.d.ts +10 -1
  66. package/dist/workspace/checks.d.ts.map +1 -1
  67. package/dist/workspace/conformance/index.d.ts +42 -2
  68. package/dist/workspace/conformance/index.d.ts.map +1 -1
  69. package/dist/workspace/conformance/vitest.d.ts.map +1 -1
  70. package/dist/workspace/decide.d.ts +184 -0
  71. package/dist/workspace/decide.d.ts.map +1 -0
  72. package/dist/workspace/decision-points.schema.json +137 -0
  73. package/dist/workspace/declaration.d.ts +51 -0
  74. package/dist/workspace/declaration.d.ts.map +1 -1
  75. package/dist/workspace/declaration.schema.json +172 -0
  76. package/dist/workspace/declared-kinds.d.ts +12 -0
  77. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  78. package/dist/workspace/points-cli.d.ts +113 -0
  79. package/dist/workspace/points-cli.d.ts.map +1 -0
  80. package/dist/workspace/points.d.ts +320 -0
  81. package/dist/workspace/points.d.ts.map +1 -0
  82. package/dist/workspace/reason-codes.d.ts +29 -0
  83. package/dist/workspace/reason-codes.d.ts.map +1 -1
  84. package/dist/workspace/record-assets.d.ts.map +1 -1
  85. package/dist/workspace/records-cli.d.ts +12 -0
  86. package/dist/workspace/records-cli.d.ts.map +1 -1
  87. package/dist/workspace/records-write.d.ts +35 -3
  88. package/dist/workspace/records-write.d.ts.map +1 -1
  89. package/dist/workspace/records.d.ts +12 -3
  90. package/dist/workspace/records.d.ts.map +1 -1
  91. package/dist/workspace/source-block.d.ts +85 -0
  92. package/dist/workspace/source-block.d.ts.map +1 -0
  93. package/dist/workspace/status-stewards.d.ts +121 -0
  94. package/dist/workspace/status-stewards.d.ts.map +1 -0
  95. package/dist/workspace/status.d.ts +52 -1
  96. package/dist/workspace/status.d.ts.map +1 -1
  97. package/dist/workspace/work-cli.d.ts +78 -0
  98. package/dist/workspace/work-cli.d.ts.map +1 -0
  99. package/package.json +1 -1
  100. package/src/cli/handlers/components.test.ts +93 -0
  101. package/src/cli/handlers/components.ts +44 -3
  102. package/src/cli/handlers/operator.ts +107 -2
  103. package/src/cli/handlers/run.test.ts +19 -0
  104. package/src/cli/handlers/run.ts +53 -1
  105. package/src/cli/handlers/serve.ts +2 -1
  106. package/src/cli/main.test.ts +40 -0
  107. package/src/cli/main.ts +78 -2
  108. package/src/cli/mcp/docs-parity.test.ts +20 -2
  109. package/src/cli/mcp/server.ts +23 -6
  110. package/src/cli/mcp/types.ts +15 -2
  111. package/src/cli/mcp/workspace-tools.test.ts +211 -0
  112. package/src/cli/mcp/workspace-tools.ts +449 -0
  113. package/src/cli/registry.ts +31 -0
  114. package/src/components/verbs/vuln-scan.test.ts +124 -1
  115. package/src/components/verbs/vuln-scan.ts +142 -1
  116. package/src/lifecycle/git.ts +65 -15
  117. package/src/lifecycle/lease.test.ts +22 -0
  118. package/src/lifecycle/lease.ts +133 -29
  119. package/src/lifecycle/member-ledger.ts +3 -2
  120. package/src/lifecycle/plan-ledger.test.ts +148 -0
  121. package/src/lifecycle/plan-ledger.ts +158 -0
  122. package/src/lifecycle/work-lease.test.ts +236 -0
  123. package/src/lifecycle/work-lease.ts +426 -0
  124. package/src/op/builders.ts +5 -0
  125. package/src/op/discover.ts +71 -0
  126. package/src/op/index.ts +16 -3
  127. package/src/op/lifecycle-receipt-store.test.ts +60 -0
  128. package/src/op/lifecycle-receipt-store.ts +61 -0
  129. package/src/op/local-executor.ts +216 -18
  130. package/src/op/local-output.ts +13 -0
  131. package/src/op/op-ir.ts +14 -0
  132. package/src/op/op-verb-class.ts +6 -0
  133. package/src/op/operator.ts +75 -4
  134. package/src/op/runtime.ts +6 -0
  135. package/src/op/runtimes/local.ts +3 -0
  136. package/src/op/step-output-ref.ts +6 -2
  137. package/src/op/steward.test.ts +212 -0
  138. package/src/op/steward.ts +253 -0
  139. package/src/op/types.ts +53 -0
  140. package/src/op/work-lease-decl.ts +80 -0
  141. package/src/op/work-lease-run.test.ts +326 -0
  142. package/src/op/work-lease-run.ts +395 -0
  143. package/src/workspace/box-isolation.test.ts +261 -0
  144. package/src/workspace/box-isolation.ts +205 -0
  145. package/src/workspace/check-contract.test.ts +3 -1
  146. package/src/workspace/check.schema.json +15 -7
  147. package/src/workspace/checks/box-isolation.ts +68 -0
  148. package/src/workspace/checks/boxes.test.ts +197 -0
  149. package/src/workspace/checks/boxes.ts +307 -0
  150. package/src/workspace/checks/records.ts +25 -0
  151. package/src/workspace/checks.test.ts +7 -0
  152. package/src/workspace/checks.ts +19 -2
  153. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
  154. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
  155. package/src/workspace/conformance/index.mjs +3 -0
  156. package/src/workspace/conformance/index.ts +185 -7
  157. package/src/workspace/conformance/vitest.ts +17 -8
  158. package/src/workspace/decide.test.ts +224 -0
  159. package/src/workspace/decide.ts +576 -0
  160. package/src/workspace/decision-points.schema.json +137 -0
  161. package/src/workspace/declaration.schema.json +172 -0
  162. package/src/workspace/declaration.ts +137 -0
  163. package/src/workspace/declared-kinds.ts +25 -2
  164. package/src/workspace/intent.schema.json +4 -1
  165. package/src/workspace/point-answer.schema.json +95 -0
  166. package/src/workspace/points-cli.ts +273 -0
  167. package/src/workspace/points-write.schema.json +489 -0
  168. package/src/workspace/points.schema.json +710 -0
  169. package/src/workspace/points.test.ts +264 -0
  170. package/src/workspace/points.ts +564 -0
  171. package/src/workspace/read-contract.test.ts +18 -1
  172. package/src/workspace/reason-codes.test.ts +14 -1
  173. package/src/workspace/reason-codes.ts +36 -0
  174. package/src/workspace/record-assets.test.ts +3 -2
  175. package/src/workspace/record-assets.ts +4 -1
  176. package/src/workspace/records-amend.schema.json +2 -1
  177. package/src/workspace/records-cli.ts +15 -2
  178. package/src/workspace/records-close.schema.json +2 -1
  179. package/src/workspace/records-contract.test.ts +3 -2
  180. package/src/workspace/records-new.schema.json +4 -1
  181. package/src/workspace/records-review.schema.json +2 -1
  182. package/src/workspace/records-write.ts +85 -7
  183. package/src/workspace/records.schema.json +18 -0
  184. package/src/workspace/records.ts +61 -3
  185. package/src/workspace/source-block.test.ts +167 -0
  186. package/src/workspace/source-block.ts +129 -0
  187. package/src/workspace/status-contract.test.ts +178 -0
  188. package/src/workspace/status-stewards.ts +225 -0
  189. package/src/workspace/status.schema.json +230 -4
  190. package/src/workspace/status.ts +106 -5
  191. package/src/workspace/work-cli.test.ts +180 -0
  192. package/src/workspace/work-cli.ts +246 -0
  193. package/src/workspace/work-lease.schema.json +233 -0
  194. package/src/workspace/work-readiness-chud.test.ts +145 -0
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Decision points (ws-058, #2738): the declaration schema, the rules checked
3
+ * in code, and the decider chain with a stub model decider.
4
+ */
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { describe, expect, test } from "vitest";
9
+ import { REPO, validSchema } from "./__fixtures__/contract-repo";
10
+ import schema from "./decision-points.schema.json";
11
+ import {
12
+ answerId,
13
+ candidates,
14
+ inputsHash,
15
+ observe,
16
+ parsePoints,
17
+ POINT_INPUT_OUTPUTS,
18
+ pointVersion,
19
+ PointsError,
20
+ runChain,
21
+ type ModelAsk,
22
+ type Point,
23
+ type WireAnswer,
24
+ } from "./points";
25
+
26
+ /**
27
+ * chud's points, as template/delivery/decisions/points.yaml declares them at
28
+ * jhgaylor/chud@43afcf1, as JSON.
29
+ */
30
+ const CHUD = {
31
+ points: {
32
+ "slice-tier": {
33
+ title: "Which builder tier builds this contract",
34
+ question: {
35
+ type: "choice",
36
+ instructions:
37
+ "Pick the smallest builder tier that can build this contract. The state is how much the contract holds and whether that fits each tier's sizing limits (lint.rules in chant.config.ts).",
38
+ criteria: {
39
+ small: "A haiku-class builder. The contract fits the small limits.",
40
+ medium: "A mid-size builder. The contract fits the medium limits.",
41
+ large: "The largest builder. The contract is bigger than the medium limits.",
42
+ },
43
+ },
44
+ inputs: {
45
+ criteria: "acceptance criteria in the contract",
46
+ files: "distinct paths the contract names",
47
+ words: "words in the body outside headings",
48
+ fits_small: "whether it is within the small tier's limits",
49
+ fits_medium: "whether it is within the medium tier's limits",
50
+ },
51
+ deciders: [
52
+ {
53
+ kind: "table",
54
+ rows: [
55
+ { when: { fits_small: true }, answer: "small" },
56
+ { when: { fits_medium: true }, answer: "medium" },
57
+ ],
58
+ },
59
+ { kind: "model", backend: "systemone", model: "bosun-v3.1-1.7b", threshold: 0.8 },
60
+ { kind: "quorum", count: 1 },
61
+ ],
62
+ },
63
+ "ship-skip": {
64
+ title: "May this release skip the human gate",
65
+ question: {
66
+ type: "boolean",
67
+ instructions: "May this release pass the ship gate without a person approving it? The state is what the release plan would change.",
68
+ criteria: {
69
+ true: "An agent may pass the gate for this release (only in enforce mode).",
70
+ false: "A person approves the release at the gate.",
71
+ },
72
+ },
73
+ inputs: {
74
+ first_release: "nothing has shipped yet",
75
+ new_migrations: "migrations that would fire",
76
+ files_changed: "files changed since the serving release",
77
+ app_changed: "whether any file under the app directory changed",
78
+ contracts_changed: "whether any file under contracts/ changed",
79
+ units: "units of work the release ships",
80
+ },
81
+ deciders: [
82
+ { kind: "table", rows: [{ when: {}, answer: false, note: "Never, until someone adds a row above this one that says when." }] },
83
+ { kind: "quorum", count: 1 },
84
+ ],
85
+ },
86
+ },
87
+ };
88
+
89
+ /** chud's points with each input named as a read-contract output: the slice tier reads a work item, the ship gate a release. */
90
+ function renamed(): typeof CHUD {
91
+ const copy = JSON.parse(JSON.stringify(CHUD)) as typeof CHUD;
92
+ const prefix: Record<string, string> = { "slice-tier": "work-item", "ship-skip": "release" };
93
+ for (const [name, point] of Object.entries(copy.points) as [string, { inputs: Record<string, string>; deciders: { rows?: { when: Record<string, unknown> }[] }[] }][]) {
94
+ const to = (k: string) => `${prefix[name]}.${k}`;
95
+ point.inputs = Object.fromEntries(Object.entries(point.inputs).map(([k, v]) => [to(k), v]));
96
+ for (const d of point.deciders) for (const r of d.rows ?? []) r.when = Object.fromEntries(Object.entries(r.when).map(([k, v]) => [to(k), v]));
97
+ }
98
+ return copy;
99
+ }
100
+
101
+ const text = (v: unknown) => JSON.stringify(v);
102
+
103
+ function problems(v: unknown): { field: string | null; message: string }[] {
104
+ try {
105
+ parsePoints(text(v), "points.json");
106
+ return [];
107
+ } catch (err) {
108
+ if (err instanceof PointsError) return err.problems;
109
+ throw err;
110
+ }
111
+ }
112
+
113
+ describe("the decision points schema (#2738)", () => {
114
+ test("is a valid draft 2020-12 document at a v1 id", () => {
115
+ expect(validSchema(schema)).toBe(true);
116
+ expect(schema.$id).toBe("https://intentius.io/chant/schemas/workspace/decision-points/v1/decision-points.schema.json");
117
+ });
118
+
119
+ test("each input output is a $defs entry of a read-contract output schema", () => {
120
+ for (const [name, o] of Object.entries(POINT_INPUT_OUTPUTS)) {
121
+ const s = JSON.parse(readFileSync(join(import.meta.dirname, `${o.schema}.schema.json`), "utf-8")) as { $defs: Record<string, unknown> };
122
+ expect(s.$defs[o.def], `${name}: ${o.schema}.schema.json has no $defs.${o.def}`).toBeDefined();
123
+ }
124
+ });
125
+
126
+ test("chud's slice-tier and ship-skip validate unchanged, apart from the input names", () => {
127
+ // As chud declares them, only the input names are refused: each names no read-contract output.
128
+ const found = problems(CHUD);
129
+ expect(found.length).toBeGreaterThan(0);
130
+ for (const p of found) expect(p.message).toMatch(/is not a read-contract output|is not one of this point's inputs/);
131
+ expect(found.filter((p) => p.message.includes("read-contract output")).map((p) => p.field)).toEqual([
132
+ ...Object.keys(CHUD.points["slice-tier"].inputs).map((k) => `points.slice-tier.inputs.${k}`),
133
+ ...Object.keys(CHUD.points["ship-skip"].inputs).map((k) => `points.ship-skip.inputs.${k}`),
134
+ ]);
135
+ // With the inputs renamed, nothing else changes and both validate.
136
+ const points = parsePoints(text(renamed()), "points.json");
137
+ expect(Object.keys(points)).toEqual(["slice-tier", "ship-skip"]);
138
+ expect(points["ship-skip"].question.type).toBe("noul");
139
+ expect(candidates(points["ship-skip"].question)).toEqual([true, false]);
140
+ expect(candidates(points["slice-tier"].question)).toEqual(["small", "medium", "large"]);
141
+ });
142
+
143
+ test("the reference workspace's points validate", () => {
144
+ const points = parsePoints(readFileSync(join(REPO, "reference-workspace", "decisions", "points.json"), "utf-8"), "decisions/points.json");
145
+ expect(Object.keys(points)).toEqual(["slice-tier", "ship-skip"]);
146
+ });
147
+
148
+ test("a point whose chain does not end in a quorum is refused", () => {
149
+ const v = renamed();
150
+ v.points["slice-tier"].deciders.pop();
151
+ expect(problems(v)).toEqual([{ field: "points.slice-tier.deciders", message: "must end in a quorum: when no table row or model answers, people do" }]);
152
+ const w = renamed();
153
+ w.points["ship-skip"].deciders.reverse();
154
+ expect(problems(w).map((p) => p.field)).toEqual(["points.ship-skip.deciders.0", "points.ship-skip.deciders"]);
155
+ });
156
+
157
+ test("a point with an unknown input is refused, and so is a row testing an undeclared one", () => {
158
+ const v = renamed() as unknown as { points: Record<string, { inputs: Record<string, string>; deciders: { rows?: { when: Record<string, unknown> }[] }[] }> };
159
+ v.points["slice-tier"].inputs["contract.size"] = "chud's contract, which the read contract has no output for";
160
+ expect(problems(v)).toEqual([
161
+ {
162
+ field: "points.slice-tier.inputs.contract.size",
163
+ message: `names "contract", which is not a read-contract output; an input is one of ${Object.keys(POINT_INPUT_OUTPUTS).join(", ")}, optionally with dotted field names`,
164
+ },
165
+ ]);
166
+ const w = renamed() as unknown as typeof v;
167
+ w.points["slice-tier"].deciders[0].rows![0].when = { "work-item.tier": "small" };
168
+ expect(problems(w)).toEqual([{ field: "points.slice-tier.deciders.0.rows.0.when.work-item.tier", message: expect.stringContaining("is not one of this point's inputs") }]);
169
+ });
170
+
171
+ test("the schema and the code refuse the rest of what chud refused, and an alias model id", () => {
172
+ const cases: [(p: Record<string, unknown>) => void, RegExp][] = [
173
+ [(p) => ((p.deciders as Record<string, unknown>[])[1].model = "jev-latest"), /is an alias/],
174
+ [(p) => ((p.deciders as Record<string, unknown>[])[1].count = 2), /is only for a quorum decider/],
175
+ [(p) => (((p.deciders as { rows: { answer: unknown }[] }[])[0].rows[0].answer = "tiny")), /must be one of "small", "medium", "large"/],
176
+ [(p) => delete (p.deciders as Record<string, unknown>[])[1].threshold, /is missing "threshold"/],
177
+ [(p) => (p.question = { type: "score", instructions: "x", criteria: ["one"] }), /must NOT have fewer than 2 items/],
178
+ [(p) => (p.extra = true), /unknown field "extra"/],
179
+ [(p) => delete p.inputs, /is missing "inputs"/],
180
+ ];
181
+ for (const [edit, message] of cases) {
182
+ const v = renamed() as unknown as { points: Record<string, Record<string, unknown>> };
183
+ edit(v.points["slice-tier"]);
184
+ expect(problems(v).map((p) => p.message).join("\n")).toMatch(message);
185
+ }
186
+ expect(problems("not json" as unknown)).toHaveLength(1);
187
+ });
188
+ });
189
+
190
+ const POINTS = parsePoints(text(renamed()), "points.json");
191
+ const SLICE = POINTS["slice-tier"];
192
+ const NOUL: Point = {
193
+ title: "Does this change need a decision",
194
+ question: { type: "noul", instructions: "Does it?", criteria: { true: "yes", false: "no" } },
195
+ inputs: { commit: "the commit" },
196
+ deciders: [
197
+ { kind: "model", backend: "systemone", model: "jev-1.13.0", threshold: 0.8 },
198
+ { kind: "quorum", count: 1 },
199
+ ],
200
+ };
201
+
202
+ /** A stub model decider: the pinned model answers with `answer`. */
203
+ const stub =
204
+ (answer: WireAnswer, model?: string): ModelAsk =>
205
+ async (req) => ({ model: model ?? req.model, answer });
206
+
207
+ const big = { "work-item.fits_small": false, "work-item.fits_medium": false };
208
+
209
+ describe("the decider chain (#2739)", () => {
210
+ test("a table row answers", async () => {
211
+ expect(await runChain("slice-tier", SLICE, { "work-item.fits_small": true })).toMatchObject({ status: "answered", answer: "small", decider: { kind: "table", row: 0 } });
212
+ });
213
+
214
+ test("a model at or above its threshold proposes, with its values", async () => {
215
+ const r = await runChain("slice-tier", SLICE, big, stub({ type: "choice", choice: "large", probabilities: { small: 0.02, medium: 0.1, large: 0.88 }, confidence: 0.8 }));
216
+ expect(r).toEqual({
217
+ status: "proposed",
218
+ answer: "large",
219
+ decider: { kind: "model", backend: "systemone", model: "bosun-v3.1-1.7b" },
220
+ probabilities: { small: 0.02, medium: 0.1, large: 0.88 },
221
+ confidence: 0.8,
222
+ threshold: 0.8,
223
+ escalations: [{ kind: "table", reason: "no row matches these inputs" }],
224
+ });
225
+ });
226
+
227
+ test("below its threshold the question escalates to the quorum, with the model's answer", async () => {
228
+ const r = await runChain("slice-tier", SLICE, big, stub({ type: "choice", choice: "medium", probabilities: { small: 0.2, medium: 0.5, large: 0.3 }, confidence: 0.25 }));
229
+ expect(r.status).toBe("escalated");
230
+ expect(r.decider).toEqual({ kind: "quorum", count: 1 });
231
+ expect(r.escalations[1]).toMatchObject({ kind: "model", answer: "medium", confidence: 0.25, threshold: 0.8, reason: "not observed: confidence 0.25 is below the threshold 0.8" });
232
+ });
233
+
234
+ test("without a backend, or with an unpinned model answering, the model is not asked or not observed", async () => {
235
+ expect((await runChain("slice-tier", SLICE, big)).escalations[1].reason).toBe("not asked: no model backend was given to this ask");
236
+ const r = await runChain("slice-tier", SLICE, big, stub({ type: "choice", choice: "large", confidence: 0.99 }, "bosun-latest"));
237
+ expect(r.status).toBe("escalated");
238
+ expect(r.escalations[1].reason).toBe("not observed: bosun-latest answered, and the point pins bosun-v3.1-1.7b");
239
+ });
240
+
241
+ test("an unreachable backend escalates, or fails when the point fails closed", async () => {
242
+ const down: ModelAsk = async () => {
243
+ throw new Error("connect ECONNREFUSED");
244
+ };
245
+ expect((await runChain("n", NOUL, {}, down)).escalations[0].reason).toBe("not observed: systemone could not answer: connect ECONNREFUSED");
246
+ const closed: Point = { ...NOUL, deciders: [{ ...(NOUL.deciders[0] as Extract<Point["deciders"][number], { kind: "model" }>), unreachable: "fail" }, NOUL.deciders[1]] };
247
+ await expect(runChain("n", closed, {}, down)).rejects.toThrow(/fails closed/);
248
+ });
249
+
250
+ test("a noul reads its probability both ways", () => {
251
+ expect(observe(NOUL.question, { type: "noul", noul: 0.9 }, 0.8)).toMatchObject({ observed: true, answer: true, confidence: 0.9 });
252
+ expect(observe(NOUL.question, { type: "noul", noul: 0.1 }, 0.8)).toMatchObject({ observed: true, answer: false, confidence: 0.9 });
253
+ expect(observe(NOUL.question, { type: "noul", noul: 0.4 }, 0.8)).toMatchObject({ observed: false, answer: false, confidence: 0.6 });
254
+ expect(observe(NOUL.question, { type: "unsupported" }, 0.8)).toMatchObject({ observed: false });
255
+ });
256
+
257
+ test("the version and inputs hash are stable over key order, and name the answer", () => {
258
+ const a = inputsHash("slice-tier", pointVersion(SLICE), { x: 1, y: [1, { b: 2, a: 1 }] });
259
+ const b = inputsHash("slice-tier", pointVersion(SLICE), { y: [1, { a: 1, b: 2 }], x: 1 });
260
+ expect(a).toBe(b);
261
+ expect(answerId("slice-tier", a)).toBe(`slice-tier-${a.slice(0, 12)}`);
262
+ expect(inputsHash("slice-tier", pointVersion({ ...SLICE, title: "changed" }), { x: 1, y: [1, { b: 2, a: 1 }] })).not.toBe(a);
263
+ });
264
+ });