@gobing-ai/ts-decision-fm 0.5.30 → 0.5.33
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 +9 -0
- package/dist/driver.d.ts +6 -5
- package/dist/driver.d.ts.map +1 -1
- package/dist/driver.js +42 -64
- package/dist/estimate.d.ts +4 -23
- package/dist/estimate.d.ts.map +1 -1
- package/dist/estimate.js +4 -88
- package/dist/fm-process.d.ts +1 -1
- package/dist/fm-process.d.ts.map +1 -1
- package/dist/fm-process.js +3 -7
- package/dist/schema.d.ts +4 -40
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +4 -102
- package/package.json +3 -3
- package/src/driver.ts +57 -85
- package/src/estimate.ts +4 -94
- package/src/fm-process.ts +8 -9
- package/src/schema.ts +11 -117
package/README.md
CHANGED
|
@@ -10,6 +10,15 @@ frequencies — the model is never asked for a confidence
|
|
|
10
10
|
|
|
11
11
|
Part of the `@gobing-ai/ts-libs` monorepo and lockstep-versioned with it.
|
|
12
12
|
|
|
13
|
+
**Built on the shared sampled-driver core.** Since task 0114 the engine-agnostic
|
|
14
|
+
half — prompt rendering, enum schema, answer parsing and the frequency
|
|
15
|
+
estimators — lives in `@gobing-ai/ts-ai-runner`
|
|
16
|
+
(`src/decision/sampled-driver.ts`). This package keeps only the `fm` process
|
|
17
|
+
bridge: the availability probe, the prompt-token budget and the per-ask schema
|
|
18
|
+
temp file. **Every export is unchanged** — `FM_INSTRUCTIONS`,
|
|
19
|
+
`buildFmSchema`, `FmSchema`, `formatDesc`, `declaredOptions`, `buildPrompt`, the
|
|
20
|
+
four estimators and `requireEnumValue` are re-exported or aliased from the core.
|
|
21
|
+
|
|
13
22
|
- **License:** Apache-2.0 (see [LICENSE](./LICENSE)).
|
|
14
23
|
- **No model weights or third-party code are bundled.** The engine is the `fm`
|
|
15
24
|
system binary on the host.
|
package/dist/driver.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Apple `fm` decision driver (task 0084 R2, R8, R9;
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* probabilities come from k sample frequencies
|
|
6
|
-
* surfaces through the decision error taxonomy —
|
|
2
|
+
* The Apple `fm` decision driver (task 0084 R2, R8, R9; task 0114 R7). Satisfies
|
|
3
|
+
* the same {@link DecisionDriver} contract as the other backends over one-shot
|
|
4
|
+
* `fm` processes, built on the shared sampled-driver core: one schema per ask
|
|
5
|
+
* answers the whole question map, probabilities come from k sample frequencies
|
|
6
|
+
* (ADR-030), and every failure surfaces through the decision error taxonomy —
|
|
7
|
+
* never as an answer.
|
|
7
8
|
*/
|
|
8
9
|
import { type DecisionDriver } from '@gobing-ai/ts-ai-runner';
|
|
9
10
|
import { type ProcessExecutor } from '@gobing-ai/ts-runtime';
|
package/dist/driver.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../src/driver.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../src/driver.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAMH,KAAK,cAAc,EAItB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAKH,KAAK,eAAe,EACvB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAEH,KAAK,YAAY,EAKpB,MAAM,cAAc,CAAC;AAEtB,wDAAwD;AACxD,MAAM,WAAW,eAAe;IAC5B,yEAAyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sFAAsF;IACtF,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,0FAA0F;IAC1F,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,4DAA4D;IAC5D,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,+DAA+D;IAC/D,UAAU,CAAC,EAAE,YAAY,CAAC;IAC1B,iCAAiC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,QAAQ,CAAC,EAAE,eAAe,CAAC;IAC3B,kGAAkG;IAClG,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kDAAkD;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,mFAAmF;AACnF,MAAM,WAAW,gBAAiB,SAAQ,cAAc;IACpD,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,mFAAmF;IACnF,QAAQ,CAAC,SAAS,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,kBAAkB,CAAC;QAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC;CACjH;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,OAAO,GAAE,eAAoB,GAAG,gBAAgB,CA2G9E"}
|
package/dist/driver.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Apple `fm` decision driver (task 0084 R2, R8, R9;
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* probabilities come from k sample frequencies
|
|
6
|
-
* surfaces through the decision error taxonomy —
|
|
2
|
+
* The Apple `fm` decision driver (task 0084 R2, R8, R9; task 0114 R7). Satisfies
|
|
3
|
+
* the same {@link DecisionDriver} contract as the other backends over one-shot
|
|
4
|
+
* `fm` processes, built on the shared sampled-driver core: one schema per ask
|
|
5
|
+
* answers the whole question map, probabilities come from k sample frequencies
|
|
6
|
+
* (ADR-030), and every failure surfaces through the decision error taxonomy —
|
|
7
|
+
* never as an answer.
|
|
7
8
|
*/
|
|
8
|
-
import { DecisionConfigError, DecisionRequestError, } from '@gobing-ai/ts-ai-runner';
|
|
9
|
+
import { buildEnumSchema, createSampledDriver, DECISION_INSTRUCTIONS, DecisionConfigError, DecisionRequestError, } from '@gobing-ai/ts-ai-runner';
|
|
9
10
|
import { createNodeFileSystem, getProcessEnv, joinPath, nodeBunFactory, } from '@gobing-ai/ts-runtime';
|
|
10
|
-
import {
|
|
11
|
-
import { countPromptTokens, parseFmRespond, probeFmAvailability, requireEnumValue, respondArgv, runFmRespond, } from './fm-process.js';
|
|
12
|
-
import { buildFmSchema, buildPrompt, declaredOptions, FM_INSTRUCTIONS } from './schema.js';
|
|
11
|
+
import { countPromptTokens, parseFmRespond, probeFmAvailability, respondArgv, runFmRespond, } from './fm-process.js';
|
|
13
12
|
/**
|
|
14
13
|
* Build the `fm-local` decision driver. Host platform/architecture is checked
|
|
15
14
|
* here, at construction, without spawning (R8); the `fm` availability probe
|
|
@@ -17,10 +16,10 @@ import { buildFmSchema, buildPrompt, declaredOptions, FM_INSTRUCTIONS } from './
|
|
|
17
16
|
*/
|
|
18
17
|
export function createFmDriver(options = {}) {
|
|
19
18
|
const { samples = 5, deterministic = false, maxPromptTokens = 6_000, requestTimeoutMs = 30_000, guardrails, fmPath = 'fm', executor = nodeBunFactory.createProcessExecutor(), platform = process.platform, arch = process.arch, } = options;
|
|
19
|
+
// Order kept from the pre-refactor driver: the samples error precedes PLATFORM.
|
|
20
20
|
if (!Number.isInteger(samples) || samples < 1) {
|
|
21
21
|
throw new DecisionRequestError(`samples must be a positive integer; got ${samples}`, undefined, undefined);
|
|
22
22
|
}
|
|
23
|
-
const k = deterministic ? 1 : samples;
|
|
24
23
|
if (platform !== 'darwin' || arch !== 'arm64') {
|
|
25
24
|
throw new DecisionConfigError(`decision-fm requires macOS on Apple Silicon (darwin arm64); current platform is '${platform} ${arch}'`, 'PLATFORM');
|
|
26
25
|
}
|
|
@@ -32,74 +31,53 @@ export function createFmDriver(options = {}) {
|
|
|
32
31
|
await probeFmAvailability(executor, fmPath, requestTimeoutMs);
|
|
33
32
|
availabilityVerified = true;
|
|
34
33
|
}
|
|
35
|
-
|
|
34
|
+
const inner = createSampledDriver({
|
|
36
35
|
name: 'fm-local',
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
const entries = Object.entries(questions);
|
|
44
|
-
if (entries.length === 0) {
|
|
45
|
-
throw new DecisionRequestError('expected a nonempty question map', undefined, undefined);
|
|
46
|
-
}
|
|
47
|
-
for (const [key, question] of entries) {
|
|
48
|
-
const optionCount = question.kind === 'choice'
|
|
49
|
-
? Object.keys(question.labels).length
|
|
50
|
-
: question.kind === 'score'
|
|
51
|
-
? question.rubric.length
|
|
52
|
-
: 2; // noul is binary by type
|
|
53
|
-
if (optionCount < 2) {
|
|
54
|
-
throw new DecisionRequestError(`question '${key}' has fewer than two options (${optionCount})`, undefined, undefined);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
36
|
+
sampleLabel: 'fm',
|
|
37
|
+
samples,
|
|
38
|
+
deterministic,
|
|
39
|
+
instructions: DECISION_INSTRUCTIONS,
|
|
40
|
+
async open({ instructions, prompt, questions }) {
|
|
57
41
|
// R8: prerequisite probe before the first sample; cached per driver.
|
|
58
42
|
await ensureFmAvailable();
|
|
59
|
-
const instructions = FM_INSTRUCTIONS;
|
|
60
|
-
const prompt = buildPrompt(state, questions);
|
|
61
43
|
// R4: pre-flight budget — over budget rejects before any `fm respond`.
|
|
62
44
|
const tokens = await countPromptTokens(executor, fmPath, instructions, prompt, requestTimeoutMs);
|
|
63
45
|
if (tokens > maxPromptTokens) {
|
|
64
46
|
throw new DecisionRequestError(`prompt is ${tokens} tokens, above the maxPromptTokens budget of ${maxPromptTokens}`, undefined, undefined);
|
|
65
47
|
}
|
|
66
48
|
// R3: one schema per ask over the whole question map, in a uniquely
|
|
67
|
-
// named temp file, deleted
|
|
49
|
+
// named temp file, deleted by `close` on success and failure.
|
|
68
50
|
const schemaPath = joinPath(getProcessEnv().TMPDIR ?? '/tmp', `fm-schema-${crypto.randomUUID()}.json`);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// R5: k sequential samples; each stdout parsed as JSON, read by key.
|
|
72
|
-
const allowed = new Map(entries.map(([key, question]) => [key, declaredOptions(question)]));
|
|
73
|
-
const samplesByKey = new Map(entries.map(([key]) => [key, []]));
|
|
74
|
-
for (let i = 0; i < k; i++) {
|
|
51
|
+
const session = {
|
|
52
|
+
async sample() {
|
|
75
53
|
const stdout = await runFmRespond(executor, fmPath, respondArgv({ schemaPath, instructions, prompt, greedy: deterministic, guardrails }), requestTimeoutMs);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
for (const [key, question] of entries) {
|
|
85
|
-
const values = samplesByKey.get(key) ?? [];
|
|
86
|
-
switch (question.kind) {
|
|
87
|
-
case 'choice':
|
|
88
|
-
answers[key] = estimateChoice(Object.keys(question.labels), values);
|
|
89
|
-
break;
|
|
90
|
-
case 'score':
|
|
91
|
-
answers[key] = estimateScore(question.rubric, values);
|
|
92
|
-
break;
|
|
93
|
-
case 'noul':
|
|
94
|
-
answers[key] = estimateNoul(values);
|
|
95
|
-
break;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return answers;
|
|
54
|
+
return parseFmRespond(stdout);
|
|
55
|
+
},
|
|
56
|
+
close() {
|
|
57
|
+
fs.deleteFile(schemaPath);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
fs.writeFile(schemaPath, JSON.stringify(buildEnumSchema(questions)));
|
|
99
62
|
}
|
|
100
|
-
|
|
63
|
+
catch (error) {
|
|
64
|
+
// Mirrors the pre-refactor `finally`: a failed write still deletes.
|
|
101
65
|
fs.deleteFile(schemaPath);
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
return session;
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
return {
|
|
72
|
+
name: 'fm-local',
|
|
73
|
+
estimator: inner.estimator,
|
|
74
|
+
// R9: only the on-device system model exists (design: fm CLI facts). The
|
|
75
|
+
// guard runs before the core so the model error keeps today's precedence.
|
|
76
|
+
async ask({ state, questions, model, }) {
|
|
77
|
+
if (model !== undefined && model !== 'system') {
|
|
78
|
+
throw new DecisionRequestError(`fm-local supports only the 'system' model (or undefined); got '${model}'`, undefined, undefined);
|
|
102
79
|
}
|
|
80
|
+
return inner.ask({ state, questions, model });
|
|
103
81
|
},
|
|
104
82
|
};
|
|
105
83
|
}
|
package/dist/estimate.d.ts
CHANGED
|
@@ -1,26 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* 1/k and `confidence` measures how much the k samples agreed, not calibration.
|
|
2
|
+
* Estimator aliases (task 0114 R8). The bodies moved to the dependency-free
|
|
3
|
+
* sampled-driver core in `@gobing-ai/ts-ai-runner`; the fm names stay exported
|
|
4
|
+
* so nothing that imported this module changes.
|
|
6
5
|
*/
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Laya reference `confidence_from_probs` on the empirical distribution:
|
|
10
|
-
* `1 − H(p) / ln(n)` over the n declared options. One-hot ⇒ 1, uniform ⇒ 0.
|
|
11
|
-
*/
|
|
12
|
-
export declare function entropyConfidence(probabilities: number[]): number;
|
|
13
|
-
/**
|
|
14
|
-
* Choice frequencies over every supplied label (unsampled ⇒ 0). The answer
|
|
15
|
-
* label is the most frequent with ties broken by label declaration order.
|
|
16
|
-
*/
|
|
17
|
-
export declare function estimateChoice(labels: readonly string[], samples: readonly string[]): ChoiceAnswer<string>;
|
|
18
|
-
/**
|
|
19
|
-
* Score frequencies over the level indices `0…n-1`. The answer score is the
|
|
20
|
-
* most frequent level with ties going to the lower level; the legend maps each
|
|
21
|
-
* index back to its rubric description.
|
|
22
|
-
*/
|
|
23
|
-
export declare function estimateScore(rubric: readonly Desc[], samples: readonly string[]): ScoreAnswer;
|
|
24
|
-
/** Noul is a bare yes-probability — no `confidence` key, by contract. */
|
|
25
|
-
export declare function estimateNoul(samples: readonly string[]): NoulAnswer;
|
|
6
|
+
export { entropyConfidence, estimateChoice, estimateNoul, estimateScore } from '@gobing-ai/ts-ai-runner';
|
|
26
7
|
//# sourceMappingURL=estimate.d.ts.map
|
package/dist/estimate.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"estimate.d.ts","sourceRoot":"","sources":["../src/estimate.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"estimate.d.ts","sourceRoot":"","sources":["../src/estimate.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAC"}
|
package/dist/estimate.js
CHANGED
|
@@ -1,90 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Estimator aliases (task 0114 R8). The bodies moved to the dependency-free
|
|
3
|
+
* sampled-driver core in `@gobing-ai/ts-ai-runner`; the fm names stay exported
|
|
4
|
+
* so nothing that imported this module changes.
|
|
4
5
|
*/
|
|
5
|
-
export
|
|
6
|
-
const n = probabilities.length;
|
|
7
|
-
if (n <= 1)
|
|
8
|
-
return 1;
|
|
9
|
-
let entropy = 0;
|
|
10
|
-
for (const p of probabilities) {
|
|
11
|
-
if (p > 0)
|
|
12
|
-
entropy -= p * Math.log(p);
|
|
13
|
-
}
|
|
14
|
-
return 1 - entropy / Math.log(n);
|
|
15
|
-
}
|
|
16
|
-
/**
|
|
17
|
-
* Choice frequencies over every supplied label (unsampled ⇒ 0). The answer
|
|
18
|
-
* label is the most frequent with ties broken by label declaration order.
|
|
19
|
-
*/
|
|
20
|
-
export function estimateChoice(labels, samples) {
|
|
21
|
-
const counts = new Map(labels.map((label) => [label, 0]));
|
|
22
|
-
for (const sample of samples)
|
|
23
|
-
counts.set(sample, (counts.get(sample) ?? 0) + 1);
|
|
24
|
-
const k = samples.length;
|
|
25
|
-
const probabilities = {};
|
|
26
|
-
const distribution = [];
|
|
27
|
-
let bestLabel = labels[0] ?? '';
|
|
28
|
-
let bestCount = -1;
|
|
29
|
-
for (const label of labels) {
|
|
30
|
-
const count = counts.get(label) ?? 0;
|
|
31
|
-
const probability = count / k;
|
|
32
|
-
distribution.push(probability);
|
|
33
|
-
probabilities[label] = probability;
|
|
34
|
-
// Declaration order wins ties: strict `>` keeps the earlier label.
|
|
35
|
-
if (count > bestCount) {
|
|
36
|
-
bestCount = count;
|
|
37
|
-
bestLabel = label;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
return {
|
|
41
|
-
kind: 'choice',
|
|
42
|
-
label: bestLabel,
|
|
43
|
-
confidence: entropyConfidence(distribution),
|
|
44
|
-
probabilities,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Score frequencies over the level indices `0…n-1`. The answer score is the
|
|
49
|
-
* most frequent level with ties going to the lower level; the legend maps each
|
|
50
|
-
* index back to its rubric description.
|
|
51
|
-
*/
|
|
52
|
-
export function estimateScore(rubric, samples) {
|
|
53
|
-
const k = samples.length;
|
|
54
|
-
const counts = [];
|
|
55
|
-
for (const sample of samples) {
|
|
56
|
-
const level = Number.parseInt(sample, 10);
|
|
57
|
-
counts[level] = (counts[level] ?? 0) + 1;
|
|
58
|
-
}
|
|
59
|
-
const probabilities = {};
|
|
60
|
-
const distribution = [];
|
|
61
|
-
let bestLevel = 0;
|
|
62
|
-
let bestCount = -1;
|
|
63
|
-
for (let level = 0; level < rubric.length; level++) {
|
|
64
|
-
const count = counts[level] ?? 0;
|
|
65
|
-
const probability = count / k;
|
|
66
|
-
distribution.push(probability);
|
|
67
|
-
probabilities[level] = probability;
|
|
68
|
-
// Lower level wins ties: strict `>` keeps the smaller index.
|
|
69
|
-
if (count > bestCount) {
|
|
70
|
-
bestCount = count;
|
|
71
|
-
bestLevel = level;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
const legend = {};
|
|
75
|
-
rubric.forEach((desc, level) => {
|
|
76
|
-
legend[level] = desc;
|
|
77
|
-
});
|
|
78
|
-
return {
|
|
79
|
-
kind: 'score',
|
|
80
|
-
score: bestLevel,
|
|
81
|
-
confidence: entropyConfidence(distribution),
|
|
82
|
-
legend,
|
|
83
|
-
probabilities,
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
/** Noul is a bare yes-probability — no `confidence` key, by contract. */
|
|
87
|
-
export function estimateNoul(samples) {
|
|
88
|
-
const yes = samples.filter((sample) => sample === 'yes').length;
|
|
89
|
-
return { kind: 'noul', probability: yes / samples.length };
|
|
90
|
-
}
|
|
6
|
+
export { entropyConfidence, estimateChoice, estimateNoul, estimateScore } from '@gobing-ai/ts-ai-runner';
|
package/dist/fm-process.d.ts
CHANGED
|
@@ -56,5 +56,5 @@ export declare function runFmRespond(executor: ProcessExecutor, fmPath: string,
|
|
|
56
56
|
*/
|
|
57
57
|
export declare function parseFmRespond(stdout: string): Record<string, string>;
|
|
58
58
|
/** A parsed value outside the declared enum is a schema violation, never an answer (R7). */
|
|
59
|
-
export declare function requireEnumValue(parsed: Record<string, string>, key: string, allowed: readonly string[]): string;
|
|
59
|
+
export declare function requireEnumValue(parsed: Record<string, string>, key: string, allowed: readonly string[], label?: string): string;
|
|
60
60
|
//# sourceMappingURL=fm-process.d.ts.map
|
package/dist/fm-process.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fm-process.d.ts","sourceRoot":"","sources":["../src/fm-process.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;
|
|
1
|
+
{"version":3,"file":"fm-process.d.ts","sourceRoot":"","sources":["../src/fm-process.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AASH,OAAO,KAAK,EAAE,eAAe,EAAiB,MAAM,uBAAuB,CAAC;AAE5E,6EAA6E;AAC7E,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,oCAAoC,CAAC;AAE5E;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAE9E;AAED,mJAAmJ;AACnJ,wBAAgB,WAAW,CAAC,OAAO,EAAE;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,sDAAsD;IACtD,UAAU,CAAC,EAAE,YAAY,CAAC;CAC7B,GAAG,MAAM,EAAE,CAYX;AAED,kFAAkF;AAClF,wBAAgB,aAAa,IAAI,MAAM,EAAE,CAExC;AAaD;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACrC,QAAQ,EAAE,eAAe,EACzB,MAAM,EAAE,MAAM,EACd,SAAS,SAAS,GACnB,OAAO,CAAC,IAAI,CAAC,CAWf;AAED;;;;;GAKG;AACH,wBAAsB,iBAAiB,CACnC,QAAQ,EAAE,eAAe,EACzB,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,MAAM,EACd,SAAS,SAAS,GACnB,OAAO,CAAC,MAAM,CAAC,CA0BjB;AAMD;;;;GAIG;AACH,wBAAsB,YAAY,CAC9B,QAAQ,EAAE,eAAe,EACzB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EAAE,EACd,gBAAgB,EAAE,MAAM,GACzB,OAAO,CAAC,MAAM,CAAC,CAwBjB;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAoBrE;AAED,4FAA4F;AAC5F,wBAAgB,gBAAgB,CAC5B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,KAAK,SAAO,GACb,MAAM,CAER"}
|
package/dist/fm-process.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* `ProcessExecutor.run` has no stdin input; fm's argv limit is far above the
|
|
7
7
|
* token budget, and the pre-flight `count-tokens` check keeps it bounded.
|
|
8
8
|
*/
|
|
9
|
-
import { DecisionBackendError, DecisionConfigError, DecisionRequestError, DecisionTimeoutError, } from '@gobing-ai/ts-ai-runner';
|
|
9
|
+
import { DecisionBackendError, DecisionConfigError, DecisionRequestError, DecisionTimeoutError, requireEnumValue as requireCoreEnumValue, } from '@gobing-ai/ts-ai-runner';
|
|
10
10
|
/**
|
|
11
11
|
* Subcommand + flag order verified against the design's live observations
|
|
12
12
|
* (task 0086 R4): instructions ride in `--instructions=<value>` and the prompt
|
|
@@ -147,10 +147,6 @@ export function parseFmRespond(stdout) {
|
|
|
147
147
|
return record;
|
|
148
148
|
}
|
|
149
149
|
/** A parsed value outside the declared enum is a schema violation, never an answer (R7). */
|
|
150
|
-
export function requireEnumValue(parsed, key, allowed) {
|
|
151
|
-
|
|
152
|
-
if (value === undefined || !allowed.includes(value)) {
|
|
153
|
-
throw new DecisionBackendError(`fm sample for '${key}' violates the schema enum: ${value === undefined ? '(missing)' : `'${value}'`}`, undefined);
|
|
154
|
-
}
|
|
155
|
-
return value;
|
|
150
|
+
export function requireEnumValue(parsed, key, allowed, label = 'fm') {
|
|
151
|
+
return requireCoreEnumValue(parsed, key, allowed, label);
|
|
156
152
|
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -1,43 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* fm request
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* The schema always carries `x-order` — a hand-written object schema without it
|
|
7
|
-
* is rejected by `fm` ("The data couldn't be read because it is missing").
|
|
8
|
-
* Score levels are string enums so every question uses the same constrained
|
|
9
|
-
* string path; the driver converts them back to numbers.
|
|
2
|
+
* fm request-construction aliases (task 0114 R8). The bodies moved to the
|
|
3
|
+
* dependency-free sampled-driver core in `@gobing-ai/ts-ai-runner`; the fm names
|
|
4
|
+
* stay exported so nothing that imported this module changes.
|
|
10
5
|
*/
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Fixed `-i` instructions. It never asks for confidence, certainty or a
|
|
14
|
-
* probability — self-reported confidence would be fabricated calibration,
|
|
15
|
-
* which the decision contract forbids (ADR-030).
|
|
16
|
-
*/
|
|
17
|
-
export declare const FM_INSTRUCTIONS: string;
|
|
18
|
-
/** The JSON schema object handed to `fm respond --schema` (R3). */
|
|
19
|
-
export interface FmSchema {
|
|
20
|
-
type: 'object';
|
|
21
|
-
title: string;
|
|
22
|
-
'x-order': string[];
|
|
23
|
-
required: string[];
|
|
24
|
-
additionalProperties: false;
|
|
25
|
-
properties: Record<string, {
|
|
26
|
-
type: 'string';
|
|
27
|
-
enum: string[];
|
|
28
|
-
}>;
|
|
29
|
-
}
|
|
30
|
-
/** Build the fm schema over the whole question map: one call answers every key. */
|
|
31
|
-
export declare function buildFmSchema(questions: Record<string, Question>): FmSchema;
|
|
32
|
-
/** Format a {@link Desc} into prompt text: strings verbatim, structured values JSON, absence empty. */
|
|
33
|
-
export declare function formatDesc(desc: Desc | undefined): string;
|
|
34
|
-
/** The declared option values of a question, in declaration order (estimation + validation share this). */
|
|
35
|
-
export declare function declaredOptions(question: Question): string[];
|
|
36
|
-
/**
|
|
37
|
-
* Render the positional prompt: the state as text (JSON when structured), then
|
|
38
|
-
* one block per question key with the prompt `Desc` and its options with
|
|
39
|
-
* descriptions — choice label keys, score level indices with the rubric text,
|
|
40
|
-
* noul `yes`/`no` with any outcome descriptions.
|
|
41
|
-
*/
|
|
42
|
-
export declare function buildPrompt(state: DecisionState, questions: Record<string, Question>): string;
|
|
6
|
+
export { buildEnumSchema as buildFmSchema, buildPrompt, DECISION_INSTRUCTIONS as FM_INSTRUCTIONS, declaredOptions, type EnumSchema as FmSchema, formatDesc, } from '@gobing-ai/ts-ai-runner';
|
|
43
7
|
//# sourceMappingURL=schema.d.ts.map
|
package/dist/schema.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACH,eAAe,IAAI,aAAa,EAChC,WAAW,EACX,qBAAqB,IAAI,eAAe,EACxC,eAAe,EACf,KAAK,UAAU,IAAI,QAAQ,EAC3B,UAAU,GACb,MAAM,yBAAyB,CAAC"}
|
package/dist/schema.js
CHANGED
|
@@ -1,104 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* fm request
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* The schema always carries `x-order` — a hand-written object schema without it
|
|
7
|
-
* is rejected by `fm` ("The data couldn't be read because it is missing").
|
|
8
|
-
* Score levels are string enums so every question uses the same constrained
|
|
9
|
-
* string path; the driver converts them back to numbers.
|
|
2
|
+
* fm request-construction aliases (task 0114 R8). The bodies moved to the
|
|
3
|
+
* dependency-free sampled-driver core in `@gobing-ai/ts-ai-runner`; the fm names
|
|
4
|
+
* stay exported so nothing that imported this module changes.
|
|
10
5
|
*/
|
|
11
|
-
|
|
12
|
-
* Fixed `-i` instructions. It never asks for confidence, certainty or a
|
|
13
|
-
* probability — self-reported confidence would be fabricated calibration,
|
|
14
|
-
* which the decision contract forbids (ADR-030).
|
|
15
|
-
*/
|
|
16
|
-
export const FM_INSTRUCTIONS = 'You are a decision engine. Answer every question below using only the supplied state. ' +
|
|
17
|
-
'For each question choose exactly one of the listed options. ' +
|
|
18
|
-
'Reply with the JSON object the schema requires.';
|
|
19
|
-
/** Build the fm schema over the whole question map: one call answers every key. */
|
|
20
|
-
export function buildFmSchema(questions) {
|
|
21
|
-
const properties = {};
|
|
22
|
-
for (const [key, question] of Object.entries(questions)) {
|
|
23
|
-
switch (question.kind) {
|
|
24
|
-
case 'choice':
|
|
25
|
-
properties[key] = { type: 'string', enum: Object.keys(question.labels) };
|
|
26
|
-
break;
|
|
27
|
-
case 'score':
|
|
28
|
-
properties[key] = { type: 'string', enum: question.rubric.map((_, level) => String(level)) };
|
|
29
|
-
break;
|
|
30
|
-
case 'noul':
|
|
31
|
-
properties[key] = { type: 'string', enum: ['yes', 'no'] };
|
|
32
|
-
break;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
const keys = Object.keys(questions);
|
|
36
|
-
return {
|
|
37
|
-
type: 'object',
|
|
38
|
-
title: 'Decision',
|
|
39
|
-
'x-order': keys,
|
|
40
|
-
required: keys,
|
|
41
|
-
additionalProperties: false,
|
|
42
|
-
properties,
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
/** Format a {@link Desc} into prompt text: strings verbatim, structured values JSON, absence empty. */
|
|
46
|
-
export function formatDesc(desc) {
|
|
47
|
-
if (typeof desc === 'string')
|
|
48
|
-
return desc;
|
|
49
|
-
if (desc === null || desc === undefined)
|
|
50
|
-
return '';
|
|
51
|
-
return JSON.stringify(desc);
|
|
52
|
-
}
|
|
53
|
-
/** The declared option values of a question, in declaration order (estimation + validation share this). */
|
|
54
|
-
export function declaredOptions(question) {
|
|
55
|
-
switch (question.kind) {
|
|
56
|
-
case 'choice':
|
|
57
|
-
return Object.keys(question.labels);
|
|
58
|
-
case 'score':
|
|
59
|
-
return question.rubric.map((_, level) => String(level));
|
|
60
|
-
case 'noul':
|
|
61
|
-
return ['yes', 'no'];
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Render the positional prompt: the state as text (JSON when structured), then
|
|
66
|
-
* one block per question key with the prompt `Desc` and its options with
|
|
67
|
-
* descriptions — choice label keys, score level indices with the rubric text,
|
|
68
|
-
* noul `yes`/`no` with any outcome descriptions.
|
|
69
|
-
*/
|
|
70
|
-
export function buildPrompt(state, questions) {
|
|
71
|
-
const parts = [];
|
|
72
|
-
const stateText = typeof state === 'string' ? state : state === null || state === undefined ? '' : JSON.stringify(state);
|
|
73
|
-
if (stateText.length > 0)
|
|
74
|
-
parts.push(stateText);
|
|
75
|
-
for (const [key, question] of Object.entries(questions)) {
|
|
76
|
-
const lines = [];
|
|
77
|
-
const prompt = formatDesc(question.prompt);
|
|
78
|
-
lines.push(`Question "${key}":${prompt.length > 0 ? ` ${prompt}` : ''}`);
|
|
79
|
-
lines.push('Options:');
|
|
80
|
-
switch (question.kind) {
|
|
81
|
-
case 'choice':
|
|
82
|
-
for (const [label, desc] of Object.entries(question.labels)) {
|
|
83
|
-
const d = formatDesc(desc);
|
|
84
|
-
lines.push(d.length > 0 ? `- ${label}: ${d}` : `- ${label}`);
|
|
85
|
-
}
|
|
86
|
-
break;
|
|
87
|
-
case 'score':
|
|
88
|
-
question.rubric.forEach((desc, level) => {
|
|
89
|
-
const d = formatDesc(desc);
|
|
90
|
-
lines.push(d.length > 0 ? `- ${level}: ${d}` : `- ${level}`);
|
|
91
|
-
});
|
|
92
|
-
break;
|
|
93
|
-
case 'noul': {
|
|
94
|
-
for (const outcome of ['yes', 'no']) {
|
|
95
|
-
const d = formatDesc(question[outcome]);
|
|
96
|
-
lines.push(d.length > 0 ? `- ${outcome}: ${d}` : `- ${outcome}`);
|
|
97
|
-
}
|
|
98
|
-
break;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
parts.push(lines.join('\n'));
|
|
102
|
-
}
|
|
103
|
-
return parts.join('\n\n');
|
|
104
|
-
}
|
|
6
|
+
export { buildEnumSchema as buildFmSchema, buildPrompt, DECISION_INSTRUCTIONS as FM_INSTRUCTIONS, declaredOptions, formatDesc, } from '@gobing-ai/ts-ai-runner';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/ts-decision-fm",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.33",
|
|
4
4
|
"description": "@gobing-ai/ts-decision-fm — Apple on-device Foundation Model decision backend driving the macOS `fm` CLI over a one-shot process bridge.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-decision-fm-v<version> && git push --tags' && exit 1"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@gobing-ai/ts-ai-runner": "^0.5.
|
|
55
|
-
"@gobing-ai/ts-runtime": "^0.5.
|
|
54
|
+
"@gobing-ai/ts-ai-runner": "^0.5.33",
|
|
55
|
+
"@gobing-ai/ts-runtime": "^0.5.33"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/bun": "1.4.3"
|
package/src/driver.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Apple `fm` decision driver (task 0084 R2, R8, R9;
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* probabilities come from k sample frequencies
|
|
6
|
-
* surfaces through the decision error taxonomy —
|
|
2
|
+
* The Apple `fm` decision driver (task 0084 R2, R8, R9; task 0114 R7). Satisfies
|
|
3
|
+
* the same {@link DecisionDriver} contract as the other backends over one-shot
|
|
4
|
+
* `fm` processes, built on the shared sampled-driver core: one schema per ask
|
|
5
|
+
* answers the whole question map, probabilities come from k sample frequencies
|
|
6
|
+
* (ADR-030), and every failure surfaces through the decision error taxonomy —
|
|
7
|
+
* never as an answer.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import {
|
|
10
11
|
type Answer,
|
|
12
|
+
buildEnumSchema,
|
|
13
|
+
createSampledDriver,
|
|
14
|
+
DECISION_INSTRUCTIONS,
|
|
11
15
|
DecisionConfigError,
|
|
12
16
|
type DecisionDriver,
|
|
13
17
|
DecisionRequestError,
|
|
@@ -21,17 +25,14 @@ import {
|
|
|
21
25
|
nodeBunFactory,
|
|
22
26
|
type ProcessExecutor,
|
|
23
27
|
} from '@gobing-ai/ts-runtime';
|
|
24
|
-
import { estimateChoice, estimateNoul, estimateScore } from './estimate';
|
|
25
28
|
import {
|
|
26
29
|
countPromptTokens,
|
|
27
30
|
type FmGuardrails,
|
|
28
31
|
parseFmRespond,
|
|
29
32
|
probeFmAvailability,
|
|
30
|
-
requireEnumValue,
|
|
31
33
|
respondArgv,
|
|
32
34
|
runFmRespond,
|
|
33
35
|
} from './fm-process';
|
|
34
|
-
import { buildFmSchema, buildPrompt, declaredOptions, FM_INSTRUCTIONS } from './schema';
|
|
35
36
|
|
|
36
37
|
/** Configuration options for {@link createFmDriver}. */
|
|
37
38
|
export interface FmDriverOptions {
|
|
@@ -80,10 +81,10 @@ export function createFmDriver(options: FmDriverOptions = {}): FmDecisionDriver
|
|
|
80
81
|
arch = process.arch,
|
|
81
82
|
} = options;
|
|
82
83
|
|
|
84
|
+
// Order kept from the pre-refactor driver: the samples error precedes PLATFORM.
|
|
83
85
|
if (!Number.isInteger(samples) || samples < 1) {
|
|
84
86
|
throw new DecisionRequestError(`samples must be a positive integer; got ${samples}`, undefined, undefined);
|
|
85
87
|
}
|
|
86
|
-
const k = deterministic ? 1 : samples;
|
|
87
88
|
|
|
88
89
|
if (platform !== 'darwin' || arch !== 'arm64') {
|
|
89
90
|
throw new DecisionConfigError(
|
|
@@ -101,53 +102,16 @@ export function createFmDriver(options: FmDriverOptions = {}): FmDecisionDriver
|
|
|
101
102
|
availabilityVerified = true;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
const inner = createSampledDriver({
|
|
105
106
|
name: 'fm-local',
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
model,
|
|
112
|
-
}: {
|
|
113
|
-
state: DecisionState;
|
|
114
|
-
questions: Record<string, Question>;
|
|
115
|
-
model?: string;
|
|
116
|
-
}): Promise<Record<string, Answer>> {
|
|
117
|
-
// R9: only the on-device system model exists (design: fm CLI facts).
|
|
118
|
-
if (model !== undefined && model !== 'system') {
|
|
119
|
-
throw new DecisionRequestError(
|
|
120
|
-
`fm-local supports only the 'system' model (or undefined); got '${model}'`,
|
|
121
|
-
undefined,
|
|
122
|
-
undefined,
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
const entries = Object.entries(questions);
|
|
126
|
-
if (entries.length === 0) {
|
|
127
|
-
throw new DecisionRequestError('expected a nonempty question map', undefined, undefined);
|
|
128
|
-
}
|
|
129
|
-
for (const [key, question] of entries) {
|
|
130
|
-
const optionCount =
|
|
131
|
-
question.kind === 'choice'
|
|
132
|
-
? Object.keys(question.labels).length
|
|
133
|
-
: question.kind === 'score'
|
|
134
|
-
? question.rubric.length
|
|
135
|
-
: 2; // noul is binary by type
|
|
136
|
-
if (optionCount < 2) {
|
|
137
|
-
throw new DecisionRequestError(
|
|
138
|
-
`question '${key}' has fewer than two options (${optionCount})`,
|
|
139
|
-
undefined,
|
|
140
|
-
undefined,
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
107
|
+
sampleLabel: 'fm',
|
|
108
|
+
samples,
|
|
109
|
+
deterministic,
|
|
110
|
+
instructions: DECISION_INSTRUCTIONS,
|
|
111
|
+
async open({ instructions, prompt, questions }) {
|
|
145
112
|
// R8: prerequisite probe before the first sample; cached per driver.
|
|
146
113
|
await ensureFmAvailable();
|
|
147
114
|
|
|
148
|
-
const instructions = FM_INSTRUCTIONS;
|
|
149
|
-
const prompt = buildPrompt(state, questions);
|
|
150
|
-
|
|
151
115
|
// R4: pre-flight budget — over budget rejects before any `fm respond`.
|
|
152
116
|
const tokens = await countPromptTokens(executor, fmPath, instructions, prompt, requestTimeoutMs);
|
|
153
117
|
if (tokens > maxPromptTokens) {
|
|
@@ -159,48 +123,56 @@ export function createFmDriver(options: FmDriverOptions = {}): FmDecisionDriver
|
|
|
159
123
|
}
|
|
160
124
|
|
|
161
125
|
// R3: one schema per ask over the whole question map, in a uniquely
|
|
162
|
-
// named temp file, deleted
|
|
126
|
+
// named temp file, deleted by `close` on success and failure.
|
|
163
127
|
const schemaPath = joinPath(getProcessEnv().TMPDIR ?? '/tmp', `fm-schema-${crypto.randomUUID()}.json`);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// R5: k sequential samples; each stdout parsed as JSON, read by key.
|
|
168
|
-
const allowed = new Map(entries.map(([key, question]) => [key, declaredOptions(question)]));
|
|
169
|
-
const samplesByKey = new Map(entries.map(([key]) => [key, [] as string[]]));
|
|
170
|
-
for (let i = 0; i < k; i++) {
|
|
128
|
+
const session = {
|
|
129
|
+
async sample(): Promise<Record<string, string>> {
|
|
171
130
|
const stdout = await runFmRespond(
|
|
172
131
|
executor,
|
|
173
132
|
fmPath,
|
|
174
133
|
respondArgv({ schemaPath, instructions, prompt, greedy: deterministic, guardrails }),
|
|
175
134
|
requestTimeoutMs,
|
|
176
135
|
);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const values = samplesByKey.get(key) ?? [];
|
|
188
|
-
switch (question.kind) {
|
|
189
|
-
case 'choice':
|
|
190
|
-
answers[key] = estimateChoice(Object.keys(question.labels), values);
|
|
191
|
-
break;
|
|
192
|
-
case 'score':
|
|
193
|
-
answers[key] = estimateScore(question.rubric, values);
|
|
194
|
-
break;
|
|
195
|
-
case 'noul':
|
|
196
|
-
answers[key] = estimateNoul(values);
|
|
197
|
-
break;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
return answers;
|
|
201
|
-
} finally {
|
|
136
|
+
return parseFmRespond(stdout);
|
|
137
|
+
},
|
|
138
|
+
close(): void {
|
|
139
|
+
fs.deleteFile(schemaPath);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
try {
|
|
143
|
+
fs.writeFile(schemaPath, JSON.stringify(buildEnumSchema(questions)));
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// Mirrors the pre-refactor `finally`: a failed write still deletes.
|
|
202
146
|
fs.deleteFile(schemaPath);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
return session;
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
name: 'fm-local',
|
|
155
|
+
estimator: inner.estimator,
|
|
156
|
+
|
|
157
|
+
// R9: only the on-device system model exists (design: fm CLI facts). The
|
|
158
|
+
// guard runs before the core so the model error keeps today's precedence.
|
|
159
|
+
async ask({
|
|
160
|
+
state,
|
|
161
|
+
questions,
|
|
162
|
+
model,
|
|
163
|
+
}: {
|
|
164
|
+
state: DecisionState;
|
|
165
|
+
questions: Record<string, Question>;
|
|
166
|
+
model?: string;
|
|
167
|
+
}): Promise<Record<string, Answer>> {
|
|
168
|
+
if (model !== undefined && model !== 'system') {
|
|
169
|
+
throw new DecisionRequestError(
|
|
170
|
+
`fm-local supports only the 'system' model (or undefined); got '${model}'`,
|
|
171
|
+
undefined,
|
|
172
|
+
undefined,
|
|
173
|
+
);
|
|
203
174
|
}
|
|
175
|
+
return inner.ask({ state, questions, model });
|
|
204
176
|
},
|
|
205
177
|
};
|
|
206
178
|
}
|
package/src/estimate.ts
CHANGED
|
@@ -1,97 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* 1/k and `confidence` measures how much the k samples agreed, not calibration.
|
|
2
|
+
* Estimator aliases (task 0114 R8). The bodies moved to the dependency-free
|
|
3
|
+
* sampled-driver core in `@gobing-ai/ts-ai-runner`; the fm names stay exported
|
|
4
|
+
* so nothing that imported this module changes.
|
|
6
5
|
*/
|
|
7
|
-
import type { ChoiceAnswer, Desc, NoulAnswer, ScoreAnswer } from '@gobing-ai/ts-ai-runner';
|
|
8
|
-
/**
|
|
9
|
-
* Laya reference `confidence_from_probs` on the empirical distribution:
|
|
10
|
-
* `1 − H(p) / ln(n)` over the n declared options. One-hot ⇒ 1, uniform ⇒ 0.
|
|
11
|
-
*/
|
|
12
|
-
export function entropyConfidence(probabilities: number[]): number {
|
|
13
|
-
const n = probabilities.length;
|
|
14
|
-
if (n <= 1) return 1;
|
|
15
|
-
let entropy = 0;
|
|
16
|
-
for (const p of probabilities) {
|
|
17
|
-
if (p > 0) entropy -= p * Math.log(p);
|
|
18
|
-
}
|
|
19
|
-
return 1 - entropy / Math.log(n);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Choice frequencies over every supplied label (unsampled ⇒ 0). The answer
|
|
24
|
-
* label is the most frequent with ties broken by label declaration order.
|
|
25
|
-
*/
|
|
26
|
-
export function estimateChoice(labels: readonly string[], samples: readonly string[]): ChoiceAnswer<string> {
|
|
27
|
-
const counts = new Map<string, number>(labels.map((label) => [label, 0]));
|
|
28
|
-
for (const sample of samples) counts.set(sample, (counts.get(sample) ?? 0) + 1);
|
|
29
|
-
const k = samples.length;
|
|
30
|
-
const probabilities: Record<string, number> = {};
|
|
31
|
-
const distribution: number[] = [];
|
|
32
|
-
let bestLabel = labels[0] ?? '';
|
|
33
|
-
let bestCount = -1;
|
|
34
|
-
for (const label of labels) {
|
|
35
|
-
const count = counts.get(label) ?? 0;
|
|
36
|
-
const probability = count / k;
|
|
37
|
-
distribution.push(probability);
|
|
38
|
-
probabilities[label] = probability;
|
|
39
|
-
// Declaration order wins ties: strict `>` keeps the earlier label.
|
|
40
|
-
if (count > bestCount) {
|
|
41
|
-
bestCount = count;
|
|
42
|
-
bestLabel = label;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return {
|
|
46
|
-
kind: 'choice',
|
|
47
|
-
label: bestLabel,
|
|
48
|
-
confidence: entropyConfidence(distribution),
|
|
49
|
-
probabilities,
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Score frequencies over the level indices `0…n-1`. The answer score is the
|
|
55
|
-
* most frequent level with ties going to the lower level; the legend maps each
|
|
56
|
-
* index back to its rubric description.
|
|
57
|
-
*/
|
|
58
|
-
export function estimateScore(rubric: readonly Desc[], samples: readonly string[]): ScoreAnswer {
|
|
59
|
-
const k = samples.length;
|
|
60
|
-
const counts: number[] = [];
|
|
61
|
-
for (const sample of samples) {
|
|
62
|
-
const level = Number.parseInt(sample, 10);
|
|
63
|
-
counts[level] = (counts[level] ?? 0) + 1;
|
|
64
|
-
}
|
|
65
|
-
const probabilities: Record<number, number> = {};
|
|
66
|
-
const distribution: number[] = [];
|
|
67
|
-
let bestLevel = 0;
|
|
68
|
-
let bestCount = -1;
|
|
69
|
-
for (let level = 0; level < rubric.length; level++) {
|
|
70
|
-
const count = counts[level] ?? 0;
|
|
71
|
-
const probability = count / k;
|
|
72
|
-
distribution.push(probability);
|
|
73
|
-
probabilities[level] = probability;
|
|
74
|
-
// Lower level wins ties: strict `>` keeps the smaller index.
|
|
75
|
-
if (count > bestCount) {
|
|
76
|
-
bestCount = count;
|
|
77
|
-
bestLevel = level;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
const legend: Record<number, Desc> = {};
|
|
81
|
-
rubric.forEach((desc, level) => {
|
|
82
|
-
legend[level] = desc;
|
|
83
|
-
});
|
|
84
|
-
return {
|
|
85
|
-
kind: 'score',
|
|
86
|
-
score: bestLevel,
|
|
87
|
-
confidence: entropyConfidence(distribution),
|
|
88
|
-
legend,
|
|
89
|
-
probabilities,
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
6
|
|
|
93
|
-
|
|
94
|
-
export function estimateNoul(samples: readonly string[]): NoulAnswer {
|
|
95
|
-
const yes = samples.filter((sample) => sample === 'yes').length;
|
|
96
|
-
return { kind: 'noul', probability: yes / samples.length };
|
|
97
|
-
}
|
|
7
|
+
export { entropyConfidence, estimateChoice, estimateNoul, estimateScore } from '@gobing-ai/ts-ai-runner';
|
package/src/fm-process.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
DecisionConfigError,
|
|
13
13
|
DecisionRequestError,
|
|
14
14
|
DecisionTimeoutError,
|
|
15
|
+
requireEnumValue as requireCoreEnumValue,
|
|
15
16
|
} from '@gobing-ai/ts-ai-runner';
|
|
16
17
|
import type { ProcessExecutor, ProcessResult } from '@gobing-ai/ts-runtime';
|
|
17
18
|
|
|
@@ -199,13 +200,11 @@ export function parseFmRespond(stdout: string): Record<string, string> {
|
|
|
199
200
|
}
|
|
200
201
|
|
|
201
202
|
/** A parsed value outside the declared enum is a schema violation, never an answer (R7). */
|
|
202
|
-
export function requireEnumValue(
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
return value;
|
|
203
|
+
export function requireEnumValue(
|
|
204
|
+
parsed: Record<string, string>,
|
|
205
|
+
key: string,
|
|
206
|
+
allowed: readonly string[],
|
|
207
|
+
label = 'fm',
|
|
208
|
+
): string {
|
|
209
|
+
return requireCoreEnumValue(parsed, key, allowed, label);
|
|
211
210
|
}
|
package/src/schema.ts
CHANGED
|
@@ -1,120 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* fm request
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* The schema always carries `x-order` — a hand-written object schema without it
|
|
7
|
-
* is rejected by `fm` ("The data couldn't be read because it is missing").
|
|
8
|
-
* Score levels are string enums so every question uses the same constrained
|
|
9
|
-
* string path; the driver converts them back to numbers.
|
|
2
|
+
* fm request-construction aliases (task 0114 R8). The bodies moved to the
|
|
3
|
+
* dependency-free sampled-driver core in `@gobing-ai/ts-ai-runner`; the fm names
|
|
4
|
+
* stay exported so nothing that imported this module changes.
|
|
10
5
|
*/
|
|
11
6
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
'You are a decision engine. Answer every question below using only the supplied state. ' +
|
|
21
|
-
'For each question choose exactly one of the listed options. ' +
|
|
22
|
-
'Reply with the JSON object the schema requires.';
|
|
23
|
-
|
|
24
|
-
/** The JSON schema object handed to `fm respond --schema` (R3). */
|
|
25
|
-
export interface FmSchema {
|
|
26
|
-
type: 'object';
|
|
27
|
-
title: string;
|
|
28
|
-
'x-order': string[];
|
|
29
|
-
required: string[];
|
|
30
|
-
additionalProperties: false;
|
|
31
|
-
properties: Record<string, { type: 'string'; enum: string[] }>;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Build the fm schema over the whole question map: one call answers every key. */
|
|
35
|
-
export function buildFmSchema(questions: Record<string, Question>): FmSchema {
|
|
36
|
-
const properties: Record<string, { type: 'string'; enum: string[] }> = {};
|
|
37
|
-
for (const [key, question] of Object.entries(questions)) {
|
|
38
|
-
switch (question.kind) {
|
|
39
|
-
case 'choice':
|
|
40
|
-
properties[key] = { type: 'string', enum: Object.keys(question.labels) };
|
|
41
|
-
break;
|
|
42
|
-
case 'score':
|
|
43
|
-
properties[key] = { type: 'string', enum: question.rubric.map((_, level) => String(level)) };
|
|
44
|
-
break;
|
|
45
|
-
case 'noul':
|
|
46
|
-
properties[key] = { type: 'string', enum: ['yes', 'no'] };
|
|
47
|
-
break;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
const keys = Object.keys(questions);
|
|
51
|
-
return {
|
|
52
|
-
type: 'object',
|
|
53
|
-
title: 'Decision',
|
|
54
|
-
'x-order': keys,
|
|
55
|
-
required: keys,
|
|
56
|
-
additionalProperties: false,
|
|
57
|
-
properties,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Format a {@link Desc} into prompt text: strings verbatim, structured values JSON, absence empty. */
|
|
62
|
-
export function formatDesc(desc: Desc | undefined): string {
|
|
63
|
-
if (typeof desc === 'string') return desc;
|
|
64
|
-
if (desc === null || desc === undefined) return '';
|
|
65
|
-
return JSON.stringify(desc);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** The declared option values of a question, in declaration order (estimation + validation share this). */
|
|
69
|
-
export function declaredOptions(question: Question): string[] {
|
|
70
|
-
switch (question.kind) {
|
|
71
|
-
case 'choice':
|
|
72
|
-
return Object.keys(question.labels);
|
|
73
|
-
case 'score':
|
|
74
|
-
return question.rubric.map((_, level) => String(level));
|
|
75
|
-
case 'noul':
|
|
76
|
-
return ['yes', 'no'];
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Render the positional prompt: the state as text (JSON when structured), then
|
|
82
|
-
* one block per question key with the prompt `Desc` and its options with
|
|
83
|
-
* descriptions — choice label keys, score level indices with the rubric text,
|
|
84
|
-
* noul `yes`/`no` with any outcome descriptions.
|
|
85
|
-
*/
|
|
86
|
-
export function buildPrompt(state: DecisionState, questions: Record<string, Question>): string {
|
|
87
|
-
const parts: string[] = [];
|
|
88
|
-
const stateText =
|
|
89
|
-
typeof state === 'string' ? state : state === null || state === undefined ? '' : JSON.stringify(state);
|
|
90
|
-
if (stateText.length > 0) parts.push(stateText);
|
|
91
|
-
for (const [key, question] of Object.entries(questions)) {
|
|
92
|
-
const lines: string[] = [];
|
|
93
|
-
const prompt = formatDesc(question.prompt);
|
|
94
|
-
lines.push(`Question "${key}":${prompt.length > 0 ? ` ${prompt}` : ''}`);
|
|
95
|
-
lines.push('Options:');
|
|
96
|
-
switch (question.kind) {
|
|
97
|
-
case 'choice':
|
|
98
|
-
for (const [label, desc] of Object.entries(question.labels)) {
|
|
99
|
-
const d = formatDesc(desc);
|
|
100
|
-
lines.push(d.length > 0 ? `- ${label}: ${d}` : `- ${label}`);
|
|
101
|
-
}
|
|
102
|
-
break;
|
|
103
|
-
case 'score':
|
|
104
|
-
question.rubric.forEach((desc, level) => {
|
|
105
|
-
const d = formatDesc(desc);
|
|
106
|
-
lines.push(d.length > 0 ? `- ${level}: ${d}` : `- ${level}`);
|
|
107
|
-
});
|
|
108
|
-
break;
|
|
109
|
-
case 'noul': {
|
|
110
|
-
for (const outcome of ['yes', 'no'] as const) {
|
|
111
|
-
const d = formatDesc(question[outcome]);
|
|
112
|
-
lines.push(d.length > 0 ? `- ${outcome}: ${d}` : `- ${outcome}`);
|
|
113
|
-
}
|
|
114
|
-
break;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
parts.push(lines.join('\n'));
|
|
118
|
-
}
|
|
119
|
-
return parts.join('\n\n');
|
|
120
|
-
}
|
|
7
|
+
export {
|
|
8
|
+
buildEnumSchema as buildFmSchema,
|
|
9
|
+
buildPrompt,
|
|
10
|
+
DECISION_INSTRUCTIONS as FM_INSTRUCTIONS,
|
|
11
|
+
declaredOptions,
|
|
12
|
+
type EnumSchema as FmSchema,
|
|
13
|
+
formatDesc,
|
|
14
|
+
} from '@gobing-ai/ts-ai-runner';
|