@fragments-sdk/classifier 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +84 -0
- package/dist/index.d.ts +184 -0
- package/dist/index.js +1856 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
- package/src/__tests__/combiner.test.ts +222 -0
- package/src/__tests__/fixtures.ts +96 -0
- package/src/ai/__tests__/cache-key.test.ts +50 -0
- package/src/ai/__tests__/prompt.test.ts +95 -0
- package/src/ai/__tests__/schema.test.ts +145 -0
- package/src/ai/__tests__/secret-scrub.test.ts +70 -0
- package/src/ai/__tests__/signal.test.ts +94 -0
- package/src/ai/cache-key.ts +46 -0
- package/src/ai/index.ts +42 -0
- package/src/ai/prompt.ts +154 -0
- package/src/ai/schema.ts +148 -0
- package/src/ai/secret-scrub.ts +116 -0
- package/src/ai/signal.ts +81 -0
- package/src/ai/version.ts +15 -0
- package/src/canonical-vocab/resolve-by-html-element.ts +72 -0
- package/src/combiner/__tests__/band.test.ts +155 -0
- package/src/combiner/__tests__/group.test.ts +85 -0
- package/src/combiner/__tests__/rank.test.ts +54 -0
- package/src/combiner/band.ts +85 -0
- package/src/combiner/group.ts +62 -0
- package/src/combiner/rank.ts +57 -0
- package/src/combiner.ts +124 -0
- package/src/index.ts +76 -0
- package/src/signals/__tests__/aria-role.test.ts +53 -0
- package/src/signals/__tests__/barrel-export.test.ts +29 -0
- package/src/signals/__tests__/html-root.test.ts +55 -0
- package/src/signals/__tests__/input-type.test.ts +58 -0
- package/src/signals/__tests__/library-reexport.test.ts +68 -0
- package/src/signals/__tests__/name-match.test.ts +43 -0
- package/src/signals/__tests__/path-hint.test.ts +55 -0
- package/src/signals/__tests__/prop-fingerprint.test.ts +105 -0
- package/src/signals/__tests__/registry.test.ts +27 -0
- package/src/signals/aria-role.ts +94 -0
- package/src/signals/barrel-export.ts +28 -0
- package/src/signals/html-root.ts +85 -0
- package/src/signals/index.ts +39 -0
- package/src/signals/input-type.ts +63 -0
- package/src/signals/library-reexport.ts +70 -0
- package/src/signals/name-match.ts +92 -0
- package/src/signals/path-hint.ts +94 -0
- package/src/signals/prop-fingerprint.ts +121 -0
- package/src/types.ts +58 -0
- package/src/vocabulary/canonicals.ts +106 -0
- package/src/vocabulary/library-map.ts +301 -0
- package/src/vocabulary/prop-fingerprints.ts +433 -0
- package/src/vocabulary/synonyms.ts +130 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Prompt version — bumped when §12.3 changes. Embedded in the cache key
|
|
2
|
+
// so that an old cached classification cannot satisfy a newer prompt.
|
|
3
|
+
export const AI_PROMPT_VERSION = 'aiprompt_v1';
|
|
4
|
+
|
|
5
|
+
// §12.4 hardening: we hard-cap the model's `reasoning` field at this many
|
|
6
|
+
// chars before persisting. Above this, the field is truncated; the model
|
|
7
|
+
// is also instructed in the prompt that reasoning must be ≤80 words.
|
|
8
|
+
export const AI_REASONING_CHAR_CAP = 200;
|
|
9
|
+
|
|
10
|
+
// §9.9 — the AI signal weights, keyed by the model's reported confidence.
|
|
11
|
+
export const AI_SIGNAL_WEIGHTS = {
|
|
12
|
+
high: 0.4,
|
|
13
|
+
medium: 0.25,
|
|
14
|
+
low: 0.1,
|
|
15
|
+
} as const;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tag → canonical resolver shared by:
|
|
3
|
+
* - `swap_to_canonical` MCP tool (`packages/mcp/src/tools/swap-to-canonical.ts`)
|
|
4
|
+
* - PR-review advisor (`apps/cloud/src/lib/pr-advisor/cluster-by-file.ts`)
|
|
5
|
+
*
|
|
6
|
+
* Both surfaces must produce identical canonical resolutions for the same
|
|
7
|
+
* input — kept in one module so a tag added in one place is automatically
|
|
8
|
+
* available to the other.
|
|
9
|
+
*
|
|
10
|
+
* Generic containers (`div`, `span`, `form`, `label`) are intentionally
|
|
11
|
+
* absent: a swap suggestion for layout markup would drown the user in
|
|
12
|
+
* noise, since every codebase uses layout primitives liberally.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface RawAttrLike {
|
|
16
|
+
value: string;
|
|
17
|
+
dynamic: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type RawAttrMap = Map<string, RawAttrLike>;
|
|
21
|
+
|
|
22
|
+
export function resolveCanonicalForHtmlElement(
|
|
23
|
+
tagName: string,
|
|
24
|
+
attrs: RawAttrMap,
|
|
25
|
+
): string | null {
|
|
26
|
+
switch (tagName) {
|
|
27
|
+
case 'button':
|
|
28
|
+
return 'Button';
|
|
29
|
+
case 'input': {
|
|
30
|
+
const type = attrs.get('type')?.value ?? 'text';
|
|
31
|
+
switch (type) {
|
|
32
|
+
case 'checkbox':
|
|
33
|
+
return 'Checkbox';
|
|
34
|
+
case 'radio':
|
|
35
|
+
return 'Radio';
|
|
36
|
+
case 'number':
|
|
37
|
+
return 'NumberInput';
|
|
38
|
+
case 'password':
|
|
39
|
+
return 'PasswordInput';
|
|
40
|
+
case 'range':
|
|
41
|
+
return 'Slider';
|
|
42
|
+
default:
|
|
43
|
+
return 'Input';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
case 'textarea':
|
|
47
|
+
return 'Textarea';
|
|
48
|
+
case 'select':
|
|
49
|
+
return 'Select';
|
|
50
|
+
case 'dialog':
|
|
51
|
+
return 'Dialog';
|
|
52
|
+
case 'progress':
|
|
53
|
+
return 'Progress';
|
|
54
|
+
case 'details':
|
|
55
|
+
return 'Disclosure';
|
|
56
|
+
case 'table':
|
|
57
|
+
return 'Table';
|
|
58
|
+
default:
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Render a raw element to a short label (`<button>` / `<input type="checkbox">`). */
|
|
64
|
+
export function formatRawHtmlElement(
|
|
65
|
+
tagName: string,
|
|
66
|
+
attrs: RawAttrMap,
|
|
67
|
+
): string {
|
|
68
|
+
if (tagName === 'input' && attrs.has('type')) {
|
|
69
|
+
return `<input type="${attrs.get('type')!.value}">`;
|
|
70
|
+
}
|
|
71
|
+
return `<${tagName}>`;
|
|
72
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { Band } from '../../types.js';
|
|
4
|
+
import { deriveBand, type BandInput } from '../band.js';
|
|
5
|
+
|
|
6
|
+
function input(partial: Partial<BandInput>): BandInput {
|
|
7
|
+
return {
|
|
8
|
+
adjustedConfidence: 0,
|
|
9
|
+
distinctStrongTypes: 0,
|
|
10
|
+
totalLeadingSignalCount: 0,
|
|
11
|
+
hasStrongSignal: false,
|
|
12
|
+
hasHighDisagreement: false,
|
|
13
|
+
isTied: false,
|
|
14
|
+
...partial,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('deriveBand', () => {
|
|
19
|
+
describe('auto', () => {
|
|
20
|
+
it('lands at auto when adjusted >= 0.83 with two distinct strong types', () => {
|
|
21
|
+
expect(
|
|
22
|
+
deriveBand(
|
|
23
|
+
input({
|
|
24
|
+
adjustedConfidence: 0.839,
|
|
25
|
+
distinctStrongTypes: 3,
|
|
26
|
+
totalLeadingSignalCount: 4,
|
|
27
|
+
hasStrongSignal: true,
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
).toBe<Band>('auto');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('does not auto with only one distinct strong type', () => {
|
|
34
|
+
expect(
|
|
35
|
+
deriveBand(
|
|
36
|
+
input({
|
|
37
|
+
adjustedConfidence: 0.9,
|
|
38
|
+
distinctStrongTypes: 1,
|
|
39
|
+
totalLeadingSignalCount: 1,
|
|
40
|
+
hasStrongSignal: true,
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
).toBe<Band>('suggested');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('does not auto when high disagreement is present', () => {
|
|
47
|
+
expect(
|
|
48
|
+
deriveBand(
|
|
49
|
+
input({
|
|
50
|
+
adjustedConfidence: 0.9,
|
|
51
|
+
distinctStrongTypes: 2,
|
|
52
|
+
totalLeadingSignalCount: 2,
|
|
53
|
+
hasStrongSignal: true,
|
|
54
|
+
hasHighDisagreement: true,
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
).toBe<Band>('possible');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('suggested', () => {
|
|
62
|
+
it('lands at suggested for the §10.1 dialog example (0.732)', () => {
|
|
63
|
+
expect(
|
|
64
|
+
deriveBand(
|
|
65
|
+
input({
|
|
66
|
+
adjustedConfidence: 0.732,
|
|
67
|
+
distinctStrongTypes: 2,
|
|
68
|
+
totalLeadingSignalCount: 3,
|
|
69
|
+
hasStrongSignal: true,
|
|
70
|
+
}),
|
|
71
|
+
),
|
|
72
|
+
).toBe<Band>('suggested');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('demotes to possible when high disagreement is present', () => {
|
|
76
|
+
expect(
|
|
77
|
+
deriveBand(
|
|
78
|
+
input({
|
|
79
|
+
adjustedConfidence: 0.7,
|
|
80
|
+
distinctStrongTypes: 1,
|
|
81
|
+
totalLeadingSignalCount: 2,
|
|
82
|
+
hasStrongSignal: true,
|
|
83
|
+
hasHighDisagreement: true,
|
|
84
|
+
}),
|
|
85
|
+
),
|
|
86
|
+
).toBe<Band>('possible');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('possible', () => {
|
|
91
|
+
it('returns possible for the §10.2 disagreement case (0.349)', () => {
|
|
92
|
+
expect(
|
|
93
|
+
deriveBand(
|
|
94
|
+
input({
|
|
95
|
+
adjustedConfidence: 0.349,
|
|
96
|
+
distinctStrongTypes: 1,
|
|
97
|
+
totalLeadingSignalCount: 1,
|
|
98
|
+
hasStrongSignal: true,
|
|
99
|
+
}),
|
|
100
|
+
),
|
|
101
|
+
).toBe<Band>('possible');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('requires at least one signal of any weight', () => {
|
|
105
|
+
expect(
|
|
106
|
+
deriveBand(
|
|
107
|
+
input({ adjustedConfidence: 0.4, totalLeadingSignalCount: 0 }),
|
|
108
|
+
),
|
|
109
|
+
).toBe<Band>('unknown');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('unknown', () => {
|
|
114
|
+
it('lands at unknown when adjusted < 0.3', () => {
|
|
115
|
+
expect(
|
|
116
|
+
deriveBand(
|
|
117
|
+
input({
|
|
118
|
+
adjustedConfidence: 0.235,
|
|
119
|
+
totalLeadingSignalCount: 2,
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
122
|
+
).toBe<Band>('unknown');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('tied', () => {
|
|
127
|
+
it('forces suggested when isTied is set', () => {
|
|
128
|
+
expect(
|
|
129
|
+
deriveBand(
|
|
130
|
+
input({
|
|
131
|
+
adjustedConfidence: 0.399,
|
|
132
|
+
distinctStrongTypes: 1,
|
|
133
|
+
totalLeadingSignalCount: 1,
|
|
134
|
+
hasStrongSignal: true,
|
|
135
|
+
isTied: true,
|
|
136
|
+
}),
|
|
137
|
+
),
|
|
138
|
+
).toBe<Band>('suggested');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('demotes from auto to suggested when tied', () => {
|
|
142
|
+
expect(
|
|
143
|
+
deriveBand(
|
|
144
|
+
input({
|
|
145
|
+
adjustedConfidence: 0.95,
|
|
146
|
+
distinctStrongTypes: 3,
|
|
147
|
+
totalLeadingSignalCount: 4,
|
|
148
|
+
hasStrongSignal: true,
|
|
149
|
+
isTied: true,
|
|
150
|
+
}),
|
|
151
|
+
),
|
|
152
|
+
).toBe<Band>('suggested');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { canonicalId, type SignalRecord } from '../../types.js';
|
|
4
|
+
import { groupAndCompose } from '../group.js';
|
|
5
|
+
|
|
6
|
+
const Dialog = canonicalId('Dialog');
|
|
7
|
+
const Button = canonicalId('Button');
|
|
8
|
+
|
|
9
|
+
function sig(
|
|
10
|
+
type: SignalRecord['type'],
|
|
11
|
+
canonical: SignalRecord['canonical'],
|
|
12
|
+
weight: number,
|
|
13
|
+
): SignalRecord {
|
|
14
|
+
return { type, canonical, weight, evidence: {} };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('groupAndCompose', () => {
|
|
18
|
+
it('returns empty array for empty input', () => {
|
|
19
|
+
expect(groupAndCompose([])).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('reproduces the §10.1 dialog example: 1 - (1-0.55)(1-0.3)(1-0.15) = 0.732', () => {
|
|
23
|
+
const result = groupAndCompose([
|
|
24
|
+
sig('LIBRARY_REEXPORT', Dialog, 0.55),
|
|
25
|
+
sig('PROP_FINGERPRINT', Dialog, 0.3),
|
|
26
|
+
sig('NAME_MATCH', Dialog, 0.15),
|
|
27
|
+
]);
|
|
28
|
+
expect(result).toHaveLength(1);
|
|
29
|
+
expect(result[0].canonical).toBe(Dialog);
|
|
30
|
+
expect(result[0].confidence).toBeCloseTo(0.732, 3);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('groups separate canonicals into independent hypotheses', () => {
|
|
34
|
+
const result = groupAndCompose([
|
|
35
|
+
sig('HTML_ROOT', Button, 0.45),
|
|
36
|
+
sig('NAME_MATCH', Dialog, 0.15),
|
|
37
|
+
]);
|
|
38
|
+
expect(result).toHaveLength(2);
|
|
39
|
+
const button = result.find((g) => g.canonical === Button);
|
|
40
|
+
const dialog = result.find((g) => g.canonical === Dialog);
|
|
41
|
+
expect(button?.confidence).toBeCloseTo(0.45, 3);
|
|
42
|
+
expect(dialog?.confidence).toBeCloseTo(0.15, 3);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('counts distinct strong signal types per canonical', () => {
|
|
46
|
+
const result = groupAndCompose([
|
|
47
|
+
sig('LIBRARY_REEXPORT', Dialog, 0.55),
|
|
48
|
+
sig('PROP_FINGERPRINT', Dialog, 0.3),
|
|
49
|
+
sig('NAME_MATCH', Dialog, 0.15),
|
|
50
|
+
]);
|
|
51
|
+
expect(result[0].distinctStrongTypes.size).toBe(2);
|
|
52
|
+
expect(result[0].distinctStrongTypes.has('LIBRARY_REEXPORT')).toBe(true);
|
|
53
|
+
expect(result[0].distinctStrongTypes.has('PROP_FINGERPRINT')).toBe(true);
|
|
54
|
+
expect(result[0].distinctStrongTypes.has('NAME_MATCH')).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('treats two records of the same strong type as one distinct strong type', () => {
|
|
58
|
+
const result = groupAndCompose([
|
|
59
|
+
sig('LIBRARY_REEXPORT', Dialog, 0.55),
|
|
60
|
+
sig('LIBRARY_REEXPORT', Dialog, 0.55),
|
|
61
|
+
]);
|
|
62
|
+
expect(result[0].distinctStrongTypes.size).toBe(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('sorts signals inside a group by weight desc, then type asc', () => {
|
|
66
|
+
const result = groupAndCompose([
|
|
67
|
+
sig('NAME_MATCH', Dialog, 0.15),
|
|
68
|
+
sig('PROP_FINGERPRINT', Dialog, 0.3),
|
|
69
|
+
sig('LIBRARY_REEXPORT', Dialog, 0.55),
|
|
70
|
+
]);
|
|
71
|
+
expect(result[0].signals.map((s) => s.type)).toEqual([
|
|
72
|
+
'LIBRARY_REEXPORT',
|
|
73
|
+
'PROP_FINGERPRINT',
|
|
74
|
+
'NAME_MATCH',
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('caps composed confidence at 1.0', () => {
|
|
79
|
+
const result = groupAndCompose([
|
|
80
|
+
sig('LIBRARY_REEXPORT', Dialog, 1.0),
|
|
81
|
+
sig('AI_SEMANTIC', Dialog, 0.4),
|
|
82
|
+
]);
|
|
83
|
+
expect(result[0].confidence).toBe(1);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { canonicalId, type SignalType } from '../../types.js';
|
|
4
|
+
import type { GroupedHypothesis } from '../group.js';
|
|
5
|
+
import { rank } from '../rank.js';
|
|
6
|
+
|
|
7
|
+
function group(
|
|
8
|
+
canonical: string,
|
|
9
|
+
confidence: number,
|
|
10
|
+
strongTypes: SignalType[] = [],
|
|
11
|
+
): GroupedHypothesis {
|
|
12
|
+
return {
|
|
13
|
+
canonical: canonicalId(canonical),
|
|
14
|
+
confidence,
|
|
15
|
+
signals: [],
|
|
16
|
+
distinctStrongTypes: new Set(strongTypes),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('rank', () => {
|
|
21
|
+
it('returns null leading for empty groups', () => {
|
|
22
|
+
const r = rank([]);
|
|
23
|
+
expect(r.leading).toBeNull();
|
|
24
|
+
expect(r.adjustedLeadingConfidence).toBe(0);
|
|
25
|
+
expect(r.alternates).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('picks the highest-confidence group as leading', () => {
|
|
29
|
+
const r = rank([group('Button', 0.7), group('Dialog', 0.4)]);
|
|
30
|
+
expect(r.leading?.canonical).toBe('Button');
|
|
31
|
+
expect(r.alternates.map((a) => a.canonical)).toEqual(['Dialog']);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('breaks confidence ties alphabetically by canonical id', () => {
|
|
35
|
+
const r = rank([group('IconButton', 0.55), group('Button', 0.55)]);
|
|
36
|
+
expect(r.leading?.canonical).toBe('Button');
|
|
37
|
+
expect(r.alternates[0].canonical).toBe('IconButton');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('applies the disagreement penalty per §10.2', () => {
|
|
41
|
+
// HTML_ROOT(Button, 0.45) + ARIA_ROLE(Link, 0.45) → Button leads,
|
|
42
|
+
// adjusted = 0.45 × (1 - 0.45 × 0.5) = 0.349.
|
|
43
|
+
const r = rank([group('Button', 0.45), group('Link', 0.45)]);
|
|
44
|
+
expect(r.rawLeadingConfidence).toBeCloseTo(0.45, 3);
|
|
45
|
+
expect(r.adjustedLeadingConfidence).toBeCloseTo(0.349, 3);
|
|
46
|
+
expect(r.maxAlternateConfidence).toBeCloseTo(0.45, 3);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('leaves a singleton hypothesis unpenalised', () => {
|
|
50
|
+
const r = rank([group('Dialog', 0.732)]);
|
|
51
|
+
expect(r.adjustedLeadingConfidence).toBeCloseTo(0.732, 3);
|
|
52
|
+
expect(r.maxAlternateConfidence).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Band derivation — `01-architecture.md` §10.3 + §10.4.
|
|
2
|
+
//
|
|
3
|
+
// Pure mapping from `(adjustedConfidence, distinctStrongTypes,
|
|
4
|
+
// totalSignalCount, hasStrongSignal, hasHighDisagreement, isTied)` to
|
|
5
|
+
// one of `auto | suggested | possible | unknown`.
|
|
6
|
+
//
|
|
7
|
+
// Strict rules from the brief:
|
|
8
|
+
// - "Strong" means weight >= 0.3.
|
|
9
|
+
// - `auto` requires two distinct strong signal *types* — not two
|
|
10
|
+
// records of the same type. The combiner counts distinct types only.
|
|
11
|
+
// - `auto` is forbidden when any non-leading hypothesis composes to
|
|
12
|
+
// `confidence >= 0.6` (high disagreement).
|
|
13
|
+
// - Tied = top-two within 0.1 absolute confidence; tie demotes the
|
|
14
|
+
// band to `suggested` regardless of signal count.
|
|
15
|
+
//
|
|
16
|
+
// Threshold deviation from spec: `auto` lower bound is 0.83, not 0.85.
|
|
17
|
+
// Spec §10.1's worked example explicitly classifies confidence 0.839
|
|
18
|
+
// as `auto` and ACCEPTANCE §5 reproduces that case verbatim. The 0.85
|
|
19
|
+
// figure in §10.3's table is internally inconsistent with §10.1.
|
|
20
|
+
// 0.83 is the smallest threshold that matches both the example and the
|
|
21
|
+
// acceptance contract; documented in brief 04 Deviations.
|
|
22
|
+
|
|
23
|
+
import type { Band } from '../types.js';
|
|
24
|
+
|
|
25
|
+
export const STRONG_WEIGHT = 0.3;
|
|
26
|
+
export const AUTO_CONFIDENCE_FLOOR = 0.83;
|
|
27
|
+
export const SUGGESTED_CONFIDENCE_FLOOR = 0.6;
|
|
28
|
+
export const POSSIBLE_CONFIDENCE_FLOOR = 0.3;
|
|
29
|
+
export const HIGH_DISAGREEMENT_FLOOR = 0.6;
|
|
30
|
+
export const TIE_WINDOW = 0.1;
|
|
31
|
+
// Tie demotion fires only when the leading raw confidence is at least
|
|
32
|
+
// `TIE_FIRES_FROM`. Otherwise two weak alternates (e.g. ARIA_ROLE for
|
|
33
|
+
// Link 0.45 vs HTML_ROOT for Button 0.45 in the §10.2 example) would
|
|
34
|
+
// be incorrectly forced into `suggested` rather than landing naturally
|
|
35
|
+
// at `possible`.
|
|
36
|
+
export const TIE_FIRES_FROM = 0.5;
|
|
37
|
+
|
|
38
|
+
export interface BandInput {
|
|
39
|
+
adjustedConfidence: number;
|
|
40
|
+
distinctStrongTypes: number;
|
|
41
|
+
totalLeadingSignalCount: number;
|
|
42
|
+
hasStrongSignal: boolean;
|
|
43
|
+
hasHighDisagreement: boolean;
|
|
44
|
+
isTied: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function deriveBand(input: BandInput): Band {
|
|
48
|
+
const {
|
|
49
|
+
adjustedConfidence,
|
|
50
|
+
distinctStrongTypes,
|
|
51
|
+
totalLeadingSignalCount,
|
|
52
|
+
hasStrongSignal,
|
|
53
|
+
hasHighDisagreement,
|
|
54
|
+
isTied,
|
|
55
|
+
} = input;
|
|
56
|
+
|
|
57
|
+
if (isTied) {
|
|
58
|
+
return 'suggested';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (
|
|
62
|
+
adjustedConfidence >= AUTO_CONFIDENCE_FLOOR &&
|
|
63
|
+
distinctStrongTypes >= 2 &&
|
|
64
|
+
!hasHighDisagreement
|
|
65
|
+
) {
|
|
66
|
+
return 'auto';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (
|
|
70
|
+
adjustedConfidence >= SUGGESTED_CONFIDENCE_FLOOR &&
|
|
71
|
+
hasStrongSignal &&
|
|
72
|
+
!hasHighDisagreement
|
|
73
|
+
) {
|
|
74
|
+
return 'suggested';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (
|
|
78
|
+
adjustedConfidence >= POSSIBLE_CONFIDENCE_FLOOR &&
|
|
79
|
+
totalLeadingSignalCount >= 1
|
|
80
|
+
) {
|
|
81
|
+
return 'possible';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return 'unknown';
|
|
85
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Group + compose — `01-architecture.md` §10.1.
|
|
2
|
+
//
|
|
3
|
+
// Groups signal records by canonical and applies independent-event
|
|
4
|
+
// probability composition `confidence(C) = 1 - ∏ (1 - wᵢ)`. The product
|
|
5
|
+
// caps at 1.0 so redundant strong signals are cumulative-but-discounted.
|
|
6
|
+
//
|
|
7
|
+
// Pure: no I/O, no clock. Output ordering is deterministic — signals
|
|
8
|
+
// inside each group are sorted by weight desc (then type ascending) so
|
|
9
|
+
// snapshot tests stay stable.
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
CanonicalId,
|
|
13
|
+
SignalRecord,
|
|
14
|
+
SignalType,
|
|
15
|
+
} from '../types.js';
|
|
16
|
+
|
|
17
|
+
export interface GroupedHypothesis {
|
|
18
|
+
canonical: CanonicalId;
|
|
19
|
+
confidence: number;
|
|
20
|
+
signals: SignalRecord[];
|
|
21
|
+
distinctStrongTypes: Set<SignalType>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const STRONG_WEIGHT = 0.3;
|
|
25
|
+
|
|
26
|
+
export function groupAndCompose(signals: SignalRecord[]): GroupedHypothesis[] {
|
|
27
|
+
const buckets = new Map<CanonicalId, SignalRecord[]>();
|
|
28
|
+
for (const sig of signals) {
|
|
29
|
+
const bucket = buckets.get(sig.canonical);
|
|
30
|
+
if (bucket) {
|
|
31
|
+
bucket.push(sig);
|
|
32
|
+
} else {
|
|
33
|
+
buckets.set(sig.canonical, [sig]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const out: GroupedHypothesis[] = [];
|
|
38
|
+
for (const [canonical, group] of buckets) {
|
|
39
|
+
const sorted = [...group].sort(compareSignalsForOutput);
|
|
40
|
+
let product = 1;
|
|
41
|
+
const distinctStrongTypes = new Set<SignalType>();
|
|
42
|
+
for (const sig of sorted) {
|
|
43
|
+
product *= 1 - sig.weight;
|
|
44
|
+
if (sig.weight >= STRONG_WEIGHT) {
|
|
45
|
+
distinctStrongTypes.add(sig.type);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
out.push({
|
|
49
|
+
canonical,
|
|
50
|
+
confidence: 1 - product,
|
|
51
|
+
signals: sorted,
|
|
52
|
+
distinctStrongTypes,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function compareSignalsForOutput(a: SignalRecord, b: SignalRecord): number {
|
|
60
|
+
if (b.weight !== a.weight) return b.weight - a.weight;
|
|
61
|
+
return a.type.localeCompare(b.type);
|
|
62
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Rank + disagreement penalty — `01-architecture.md` §10.2.
|
|
2
|
+
//
|
|
3
|
+
// Picks the leading hypothesis by composed confidence, computes the max
|
|
4
|
+
// alternate confidence, and applies the disagreement penalty to the
|
|
5
|
+
// leading hypothesis only:
|
|
6
|
+
//
|
|
7
|
+
// adjusted_leading = leading × (1 - max_alternate × 0.5)
|
|
8
|
+
//
|
|
9
|
+
// Ties on confidence resolve alphabetically by canonical id so the
|
|
10
|
+
// pipeline output is deterministic across runs (idempotence per §11.2).
|
|
11
|
+
|
|
12
|
+
import type { GroupedHypothesis } from './group.js';
|
|
13
|
+
|
|
14
|
+
export interface RankResult {
|
|
15
|
+
leading: GroupedHypothesis | null;
|
|
16
|
+
rawLeadingConfidence: number;
|
|
17
|
+
adjustedLeadingConfidence: number;
|
|
18
|
+
alternates: GroupedHypothesis[];
|
|
19
|
+
maxAlternateConfidence: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function rank(groups: GroupedHypothesis[]): RankResult {
|
|
23
|
+
if (groups.length === 0) {
|
|
24
|
+
return {
|
|
25
|
+
leading: null,
|
|
26
|
+
rawLeadingConfidence: 0,
|
|
27
|
+
adjustedLeadingConfidence: 0,
|
|
28
|
+
alternates: [],
|
|
29
|
+
maxAlternateConfidence: 0,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sorted = [...groups].sort(compareByConfidence);
|
|
34
|
+
const leading = sorted[0];
|
|
35
|
+
const alternates = sorted.slice(1);
|
|
36
|
+
const maxAlternateConfidence =
|
|
37
|
+
alternates.length > 0 ? alternates[0].confidence : 0;
|
|
38
|
+
const rawLeadingConfidence = leading.confidence;
|
|
39
|
+
const adjustedLeadingConfidence =
|
|
40
|
+
rawLeadingConfidence * (1 - maxAlternateConfidence * 0.5);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
leading,
|
|
44
|
+
rawLeadingConfidence,
|
|
45
|
+
adjustedLeadingConfidence,
|
|
46
|
+
alternates,
|
|
47
|
+
maxAlternateConfidence,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function compareByConfidence(
|
|
52
|
+
a: GroupedHypothesis,
|
|
53
|
+
b: GroupedHypothesis,
|
|
54
|
+
): number {
|
|
55
|
+
if (b.confidence !== a.confidence) return b.confidence - a.confidence;
|
|
56
|
+
return a.canonical.localeCompare(b.canonical);
|
|
57
|
+
}
|