create-t2k 0.1.0 → 0.3.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/README.md CHANGED
@@ -4,7 +4,7 @@ Create a runnable local T2K project with a synthetic ontology, accepted facts,
4
4
  a Decision Context, two executable policies, and disjoint replay evidence.
5
5
 
6
6
  ```bash
7
- npx create-t2k my-decision-loop
7
+ npx create-t2k@latest my-decision-loop
8
8
  cd my-decision-loop
9
9
  npm start
10
10
  ```
@@ -14,10 +14,44 @@ baseline and challenger policies against the current facts, computes a held-out
14
14
  replay comparison, and emits a recommendation that still requires explicit
15
15
  human authorization.
16
16
 
17
+ The generated project also includes a PostgreSQL 16 Compose service and a
18
+ persisted golden path:
19
+
20
+ ```bash
21
+ npm run db:up
22
+ npm run lifecycle
23
+ ```
24
+
25
+ That command records authorization, execution receipts, observations, computed
26
+ rewards, held-out evaluation, independent promotion, and exact rollback in the
27
+ open reference runtime.
28
+
29
+ Stop the local containers without deleting lifecycle data with `npm run
30
+ db:down`. Use the explicitly destructive `npm run db:reset` only when you
31
+ intend to delete the disposable local database volume.
32
+
33
+ To expose ontology validation, compilation, policy execution, replay, and
34
+ reward evaluation to an MCP host, add:
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "t2k": {
40
+ "command": "npx",
41
+ "args": ["-y", "@t2kai/mcp@latest"]
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ This starts read-only and does not send project data to a hosted service. See
48
+ the [`@t2kai/mcp` guide](https://github.com/sigaihealth/t2k-core/blob/main/packages/mcp/README.md)
49
+ before enabling database access or agent writes.
50
+
17
51
  Use `--no-install` to generate files without running `npm install`:
18
52
 
19
53
  ```bash
20
- npx create-t2k my-decision-loop --no-install
54
+ npx create-t2k@latest my-decision-loop --no-install
21
55
  ```
22
56
 
23
57
  The command refuses to write into a non-empty directory. Node.js 20.10 or newer
@@ -24,7 +24,8 @@ Options:
24
24
  -v, --version Show the package version
25
25
 
26
26
  The default directory is my-t2k-project. Existing non-empty directories are
27
- never overwritten.`;
27
+ never overwritten. Generated projects include decision and optional
28
+ persisted-lifecycle examples.`;
28
29
 
29
30
  try {
30
31
  const options = parseArguments(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-t2k",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Create a local T2K governed-decision project in minutes.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
package/src/scaffold.mjs CHANGED
@@ -170,6 +170,8 @@ export async function scaffoldProject({
170
170
  }
171
171
  stdout.write(" npm start\n\n");
172
172
  stdout.write("The first run computes a recommendation; a human must still authorize it.\n");
173
+ stdout.write("Run `npm run db:up && npm run lifecycle` for the persisted closed loop.\n");
174
+ stdout.write("Use `npm run db:down` to stop it or `npm run db:reset` to delete its volume.\n");
173
175
 
174
176
  return { targetPath, projectName };
175
177
  }
@@ -19,6 +19,51 @@ The run performs five explicit steps:
19
19
  The output is not an autonomous authorization. The generated Decision Context
20
20
  requires a `dispatch_owner` to review the evidence and authorize any action.
21
21
 
22
+ ## Run the complete persisted loop
23
+
24
+ Start the included PostgreSQL 16 service, then execute the lifecycle:
25
+
26
+ ```bash
27
+ npm run db:up
28
+ npm run lifecycle
29
+ ```
30
+
31
+ This path persists 24 synthetic episodes and proves the full sequence:
32
+
33
+ ```text
34
+ context -> recommendation -> human authorization -> execution receipt
35
+ -> observation -> computed reward -> held-out replay
36
+ -> human promotion -> exact rollback
37
+ ```
38
+
39
+ The actors who propose, evaluate, promote, and roll back are deliberately
40
+ separate synthetic identities. The event ledger is hash-chained and append-only.
41
+ In production, authenticate those actor IDs and enforce organization roles in
42
+ the calling service; the local runtime is not an identity provider. To remove
43
+ the local containers while preserving their volume, run `npm run db:down`. To
44
+ explicitly delete the disposable local volume and all lifecycle data in it, run
45
+ `npm run db:reset`.
46
+
47
+ ## Connect an MCP host
48
+
49
+ Expose validation, compilation, policy execution, replay, and reward evaluation
50
+ to an MCP host with the safe database-free mode:
51
+
52
+ ```json
53
+ {
54
+ "mcpServers": {
55
+ "t2k": {
56
+ "command": "npx",
57
+ "args": ["-y", "@t2kai/mcp@latest"]
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ The MCP server does not expose human authorization or promotion operations.
64
+ Read <https://t2k.ai/developers/> before enabling its optional Postgres or agent
65
+ mutation modes.
66
+
22
67
  ## Change the example
23
68
 
24
69
  - Change current facts in `decision-context.json`.
@@ -26,6 +71,7 @@ requires a `dispatch_owner` to review the evidence and authorize any action.
26
71
  - Add observed outcomes to `episodes/holdout.json` without reusing training data.
27
72
  - Change concepts and decision contracts in `ontology-pack.json`.
28
73
 
29
- The quickstart uses local files and `@t2kai/core`; it does not send data to a
30
- hosted service. Persistence, receipts, promotion, and rollback require the
31
- portable lifecycle runtime or hosted Studio and are not simulated here.
74
+ The quickstart uses local files, local PostgreSQL, and `@t2kai/core`; it does not
75
+ send data to a hosted service. Set `T2K_DATABASE_URL` to use another Postgres
76
+ database. Use a dedicated database because the runtime owns the
77
+ `t2k_reference` schema.
@@ -0,0 +1,19 @@
1
+ services:
2
+ postgres:
3
+ image: postgres:16-alpine
4
+ environment:
5
+ POSTGRES_DB: t2k_reference
6
+ POSTGRES_PASSWORD: t2k
7
+ POSTGRES_USER: t2k
8
+ healthcheck:
9
+ test: ["CMD-SHELL", "pg_isready -U t2k -d t2k_reference"]
10
+ interval: 2s
11
+ timeout: 5s
12
+ retries: 15
13
+ ports:
14
+ - "55432:5432"
15
+ volumes:
16
+ - t2k-reference-data:/var/lib/postgresql/data
17
+
18
+ volumes:
19
+ t2k-reference-data:
@@ -8,9 +8,13 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node src/run.mjs",
11
- "check": "node src/run.mjs"
11
+ "check": "node src/run.mjs",
12
+ "db:up": "docker compose up -d --wait",
13
+ "db:down": "docker compose down",
14
+ "db:reset": "docker compose down -v",
15
+ "lifecycle": "node src/lifecycle.mjs"
12
16
  },
13
17
  "dependencies": {
14
- "@t2kai/core": "^0.1.0"
18
+ "@t2kai/core": "^0.3.0"
15
19
  }
16
20
  }
@@ -0,0 +1,313 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import process from "node:process";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ import { validateOntologyPackManifest } from "@t2kai/core";
9
+ import { compileOntologyPackSet } from "@t2kai/core/compiler";
10
+ import { PostgresReferenceLifecycle } from "@t2kai/core/postgres";
11
+
12
+ const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
+ const readJson = async (relativePath) =>
14
+ JSON.parse(await fs.readFile(path.join(projectRoot, relativePath), "utf8"));
15
+ const [manifest, baseline, candidatePolicy, holdout] = await Promise.all([
16
+ readJson("ontology-pack.json"),
17
+ readJson("policies/baseline.json"),
18
+ readJson("policies/candidate.json"),
19
+ readJson("episodes/holdout.json"),
20
+ ]);
21
+
22
+ const validation = validateOntologyPackManifest(manifest);
23
+ assert.equal(validation.valid, true, JSON.stringify(validation.errors));
24
+ const compilation = compileOntologyPackSet({
25
+ manifests: [manifest],
26
+ roots: [{ ontologyId: manifest.ontologyId, version: manifest.ontologyVersion }],
27
+ });
28
+ assert.equal(compilation.status, "valid", JSON.stringify(compilation.diagnostics));
29
+
30
+ const template = manifest.decisionTemplates[0];
31
+ const learningContract = template.learningContract;
32
+ const runId = randomUUID().replaceAll("-", "").slice(0, 10);
33
+ const policyKey = `${baseline.policyKey}-${runId}`;
34
+ const decisionType = `${template.decisionType}.${runId}`;
35
+ const connectionString =
36
+ process.env.T2K_DATABASE_URL ??
37
+ process.env.DATABASE_URL ??
38
+ process.env.T2K_TEST_DATABASE_URL ??
39
+ "postgresql://t2k:t2k@127.0.0.1:55432/t2k_reference";
40
+
41
+ const actors = {
42
+ proposer: { actorType: "agent", actorId: "agent:policy-builder" },
43
+ reviewer: { actorType: "human", actorId: "human:dispatch-owner" },
44
+ evaluator: { actorType: "human", actorId: "human:policy-evaluator" },
45
+ promoter: { actorType: "human", actorId: "human:policy-promoter" },
46
+ rollback: { actorType: "human", actorId: "human:rollback-reviewer" },
47
+ reward: { actorType: "system", actorId: "system:reward-engine" },
48
+ };
49
+ const lifecycle = new PostgresReferenceLifecycle({ connectionString });
50
+ const trainingEpisodeIds = [];
51
+ const holdoutEpisodeIds = [];
52
+
53
+ const sha256 = (value) =>
54
+ createHash("sha256").update(JSON.stringify(value)).digest("hex");
55
+
56
+ async function createAcceptedVersion(version, specification, parentVersionId) {
57
+ const proposed = await lifecycle.createPolicyVersion(
58
+ policyKey,
59
+ {
60
+ policyVersion: version,
61
+ learningMode: learningContract.mode,
62
+ specification,
63
+ rewardSpec: learningContract.rewardSpec,
64
+ parentVersionId,
65
+ rationale: `Synthetic reference policy ${version}.`,
66
+ },
67
+ actors.proposer
68
+ );
69
+ return lifecycle.acceptPolicyVersion(
70
+ policyKey,
71
+ proposed.policyVersion,
72
+ "An independent human accepted the deterministic policy.",
73
+ actors.reviewer
74
+ );
75
+ }
76
+
77
+ async function persistFixture(fixture, index) {
78
+ const key = `${runId}:${fixture.cohort}:${fixture.fixtureId}`;
79
+ const pressure = fixture.state.facts[0].objectValue;
80
+ const context = await lifecycle.createDecisionContext(
81
+ {
82
+ contextKey: `context:${key}`,
83
+ question: template.question,
84
+ decisionType,
85
+ stateSnapshot: fixture.state,
86
+ objective: { maximize: template.successMeasure },
87
+ constraints: template.policies,
88
+ requiredAuthority: { role: template.authority },
89
+ learningContract,
90
+ },
91
+ actors.proposer
92
+ );
93
+ const recommendation = await lifecycle.recommend(
94
+ context.contextKey,
95
+ { recommendationKey: `recommendation:${key}` },
96
+ actors.proposer
97
+ );
98
+ assert.equal(recommendation.proposedAction, fixture.loggedAction);
99
+ const authorization = await lifecycle.authorizeRecommendation(
100
+ recommendation.id,
101
+ { rationale: "The dispatch owner authorizes this reversible synthetic action." },
102
+ actors.reviewer
103
+ );
104
+ const episode = await lifecycle.openEpisode(
105
+ {
106
+ episodeKey: `episode:${key}`,
107
+ contextKey: context.contextKey,
108
+ authorizedDecisionId: authorization.id,
109
+ externalEffect: true,
110
+ },
111
+ actors.proposer
112
+ );
113
+
114
+ const request = { action: fixture.loggedAction, pressure };
115
+ const response = { applied: true, synthetic: true };
116
+ await lifecycle.recordExecutionReceipt(
117
+ episode.id,
118
+ {
119
+ receiptKey: `receipt:${key}`,
120
+ idempotencyKey: `idempotency:${key}`,
121
+ connectorRef: "synthetic.harborlight.dispatch",
122
+ externalTransactionId: `dispatch-${key}`,
123
+ outcome: "succeeded",
124
+ requestHash: sha256(request),
125
+ responseHash: sha256(response),
126
+ response,
127
+ rollbackContract: { operation: "restore_prior_dispatch_plan" },
128
+ reconciliationStatus: "reconciled",
129
+ },
130
+ actors.proposer
131
+ );
132
+ await lifecycle.recordObservation(
133
+ episode.id,
134
+ {
135
+ measureRef: learningContract.rewardSpec[0].measureRef,
136
+ observedValue: 0.4 + fixture.scalarReward * 0.4,
137
+ baselineValue: 0.4,
138
+ unit: "ratio",
139
+ observationWindow: "7d",
140
+ sourceRefs: [`fixture://harborlight/${key}`],
141
+ provenance: { cohort: fixture.cohort, synthetic: true },
142
+ attributionConfidence: 1,
143
+ observedAt: new Date(Date.UTC(2026, 0, index + 1)).toISOString(),
144
+ },
145
+ actors.proposer
146
+ );
147
+ const reward = await lifecycle.assessReward(
148
+ episode.id,
149
+ {
150
+ assessmentKey: `assessment:${key}`,
151
+ attribution: { method: "deterministic_synthetic_fixture" },
152
+ },
153
+ actors.reward
154
+ );
155
+ assert.equal(reward.lifecycleStatus, "complete");
156
+ await lifecycle.closeEpisode(
157
+ episode.id,
158
+ "The receipt and outcome observation are complete.",
159
+ actors.reviewer
160
+ );
161
+ (fixture.cohort === "training" ? trainingEpisodeIds : holdoutEpisodeIds).push(
162
+ episode.id
163
+ );
164
+ }
165
+
166
+ const behaviorTraining = [
167
+ {
168
+ fixtureId: "training-rebalance-01",
169
+ cohort: "training",
170
+ state: { facts: [{ objectValue: 0.82 }] },
171
+ loggedAction: "rebalance_route",
172
+ scalarReward: 0.8,
173
+ },
174
+ {
175
+ fixtureId: "training-rebalance-02",
176
+ cohort: "training",
177
+ state: { facts: [{ objectValue: 0.74 }] },
178
+ loggedAction: "rebalance_route",
179
+ scalarReward: 0.7,
180
+ },
181
+ ];
182
+ const baselineTraining = [
183
+ {
184
+ fixtureId: "training-hold-01",
185
+ cohort: "training",
186
+ state: { facts: [{ objectValue: 0.31 }] },
187
+ loggedAction: "hold",
188
+ scalarReward: 0,
189
+ },
190
+ {
191
+ fixtureId: "training-hold-02",
192
+ cohort: "training",
193
+ state: { facts: [{ objectValue: 0.22 }] },
194
+ loggedAction: "hold",
195
+ scalarReward: 0.05,
196
+ },
197
+ ];
198
+ const normalizedHoldout = holdout.map((episode) => ({
199
+ ...episode,
200
+ fixtureId: episode.episodeId,
201
+ cohort: "holdout",
202
+ }));
203
+
204
+ try {
205
+ const schemaVersion = await lifecycle.migrate();
206
+ await lifecycle.createPolicy(
207
+ {
208
+ policyKey,
209
+ label: "Harborlight dispatch policy",
210
+ decisionType,
211
+ description: "A synthetic persisted decision-learning loop.",
212
+ },
213
+ actors.proposer
214
+ );
215
+
216
+ const behavior = await createAcceptedVersion(
217
+ "0.9.0",
218
+ candidatePolicy.specification
219
+ );
220
+ await lifecycle.deployPolicyVersion(policyKey, "0.9.0", actors.reviewer);
221
+ const behaviorFixtures = [
222
+ ...behaviorTraining,
223
+ ...normalizedHoldout.filter((episode) => episode.loggedAction === "rebalance_route"),
224
+ ];
225
+ for (const [index, fixture] of behaviorFixtures.entries()) {
226
+ await persistFixture(fixture, index);
227
+ }
228
+
229
+ const baselineVersion = await createAcceptedVersion(
230
+ baseline.version,
231
+ baseline.specification,
232
+ behavior.id
233
+ );
234
+ await lifecycle.deployPolicyVersion(policyKey, baseline.version, actors.reviewer);
235
+ const baselineFixtures = [
236
+ ...baselineTraining,
237
+ ...normalizedHoldout.filter((episode) => episode.loggedAction !== "rebalance_route"),
238
+ ];
239
+ for (const [index, fixture] of baselineFixtures.entries()) {
240
+ await persistFixture(fixture, behaviorFixtures.length + index);
241
+ }
242
+
243
+ assert.equal(trainingEpisodeIds.length, 4);
244
+ assert.equal(holdoutEpisodeIds.length, 20);
245
+ const candidate = await lifecycle.createCandidate(
246
+ {
247
+ candidateKey: `candidate:${runId}:${candidatePolicy.version}`,
248
+ policyKey,
249
+ sourcePolicyVersionId: baselineVersion.id,
250
+ proposedPolicyVersion: candidatePolicy.version,
251
+ proposedSpecification: candidatePolicy.specification,
252
+ trainingEpisodeIds,
253
+ rationale: "Training evidence supports route rebalancing under pressure.",
254
+ },
255
+ actors.proposer
256
+ );
257
+ const replay = await lifecycle.evaluateCandidate(
258
+ candidate.id,
259
+ {
260
+ evaluationKey: `replay:${runId}:${candidatePolicy.version}`,
261
+ holdoutEpisodeIds,
262
+ evidenceRefs: [`fixture://harborlight/${runId}/holdout`],
263
+ },
264
+ actors.evaluator
265
+ );
266
+ assert.equal(replay.lifecycleStatus, "passed");
267
+
268
+ const promoted = await lifecycle.promoteCandidate(
269
+ candidate.id,
270
+ {
271
+ reviewRationale: "Computed held-out replay passed the promotion threshold.",
272
+ deploy: true,
273
+ },
274
+ actors.promoter
275
+ );
276
+ const rollback = await lifecycle.rollbackPromotion(
277
+ promoted.promotion.id,
278
+ "Restore the exact parent after proving rollback.",
279
+ actors.rollback
280
+ );
281
+ const restored = await lifecycle.getActivePolicy(decisionType);
282
+ assert.equal(restored?.version.id, baselineVersion.id);
283
+ const eventChain = await lifecycle.verifyEventChain();
284
+ assert.equal(eventChain.valid, true);
285
+
286
+ console.log(
287
+ JSON.stringify(
288
+ {
289
+ status: "passed",
290
+ ontology: `${manifest.ontologyId}@${manifest.ontologyVersion}`,
291
+ resolutionHash: compilation.resolutionHash,
292
+ runId,
293
+ schemaVersion,
294
+ persisted: { episodes: 24, executionReceipts: 24 },
295
+ replay: replay.metrics,
296
+ promotion: {
297
+ version: promoted.policyVersion.policyVersion,
298
+ status: promoted.promotion.lifecycleStatus,
299
+ },
300
+ rollback: {
301
+ status: rollback.lifecycleStatus,
302
+ restoredVersion: restored.version.policyVersion,
303
+ exactParentRestored: true,
304
+ },
305
+ eventChain,
306
+ },
307
+ null,
308
+ 2
309
+ )
310
+ );
311
+ } finally {
312
+ await lifecycle.close();
313
+ }