@haus-tech/haus-workflow 5.0.0 → 5.1.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.
@@ -3,17 +3,31 @@
3
3
  * MEDIUM+). Poängen har större driftyta än klassens enum och multipliceras
4
4
  * direkt till pris — request-score:s eget acceptanstest är att två oberoende
5
5
  * körningar ger samma poäng. Jämförelsefälten är outcome, score och
6
- * inringningens golv- och takankare. Prosa (why, drivers, rationale) jämförs
7
- * ALDRIG. Confidence (enum, inte prosa) visas alltid i utskriften, men fäller
6
+ * inringningens golv- och takankare, samt type (toleransrad), antalet
7
+ * conditions (hård diff) och alternatives per label (#509). Prosa (why,
8
+ * drivers, rationale) jämförs ALDRIG. Confidence (enum, inte prosa) visas
9
+ * alltid i utskriften, men fäller
8
10
  * bara ett par utanför toleransen, se nedan.
9
11
  *
10
12
  * Toleransregel: en poängskillnad ≤ 0,5 med samma outcome är prisbrus, inte
11
13
  * oenighet, oavsett confidence. En ankarskillnad tolereras dessutom bara när
12
14
  * banden överlappar eller angränsar; icke-angränsande band är hård diff även
13
- * vid samma poäng. LIKA_TOLERANS hör till LIKA-familjen (exit 0) med
15
+ * vid samma poäng. Angränsning avgörs av skalans ordning i lib.mjs ANKARE,
16
+ * inte av ankarnumret (A17 ligger mellan A12 och A13). LIKA_TOLERANS hör till
17
+ * LIKA-familjen (exit 0) med
14
18
  * en deterministisk vinnare (högsta poängen, skyddar marginalen). Den
15
19
  * skriver aldrig om en fil och räknar aldrig fram ett medelvärde.
16
20
  *
21
+ * Stegdiff (beslut D 2026-09-02, ADR-0107 amends 0078): två körningar som
22
+ * läser SAMMA inre ankare i bracket.steps åt motsatt håll (storre ↔ mindre)
23
+ * är hård diff, aldrig toleransrad, även när poängen ligger inom 0,5. Golv-
24
+ * och takankare i någon av körningarna undantas: när banden angränsar
25
+ * (A11/A12 mot A12/A13) flippar det delade gränsankaret av konstruktion, och
26
+ * det fallet äger redan angränsningstoleransen ovan. Utan undantaget hade
27
+ * varje angränsande par blivit OLIKA och toleransen upphävts i praktiken.
28
+ * Ankare som bara finns i ena körningen, saknat eller trasigt steps (äldre
29
+ * arkiv) ger ingen rad.
30
+ *
17
31
  * Skriver LIKA, LIKA_TOLERANS (exit 0) eller OLIKA + diffrader (exit 1) på
18
32
  * stdout; varningar går på stderr. Vid OLIKA väljer dispatchern ALDRIG en
19
33
  * körning och räknar ALDRIG medelvärde — posten eskaleras till granskaren med
@@ -25,10 +39,10 @@
25
39
  *
26
40
  * Användning: node compare-scores.mjs <körning1.json> <körning2.json>
27
41
  */
28
- import { loadRunPair, refDriftWarning, reportDiffs } from './lib.mjs'
42
+ import { ankarPosition, loadRunPair, norm, refDriftWarning, reportDiffs } from './lib.mjs'
29
43
 
30
44
  // Jämförelsefälten speglar poängskalans kontrakt — varna (stderr) vid katalogdrift.
31
- const REF_SHA = 'cb5a6c221b5d39c445ca31a427fd3f96c648388ac5441c22ea2ea5f72e1f8e31'
45
+ const REF_SHA = '810a71f2767173bdc1747e932f466f3fc25cb412b84f61fab0ca0894ff80438a'
32
46
  {
33
47
  const w = refDriftWarning('request-score/references/poangskala.md', REF_SHA)
34
48
  if (w) console.error(`VARNING: ${w}`)
@@ -50,16 +64,19 @@ if (a.outcome !== b.outcome) diffs.push(`outcome: ${show(a.outcome)} ↔ ${show(
50
64
  // fortfarande aldrig.
51
65
  const TOLERANS = 0.5
52
66
 
53
- /**
54
- * Ankarnummer på skalan A1..A17. Golv null (under-A1-fallet) läses som 0, alltså
55
- * steget under A1. Ett ankare som inte går att tolka ger null och behandlas som
56
- * icke-angränsande, så en oläslig inringning aldrig kan tystas som brus.
57
- */
67
+ /** Ankarnummer, ENBART för deterministisk utskriftsordning av stegrader. Aldrig ett skalläge, se position(). */
58
68
  const ankarNr = (v) => {
59
69
  if (v === null || v === undefined) return 0
60
70
  const m = /^A(\d+)$/.exec(String(v))
61
71
  return m ? Number(m[1]) : null
62
72
  }
73
+ /**
74
+ * Position i skalans ordning ur lib.mjs ANKARE. Golv null (under-A1-fallet)
75
+ * läses som steget under A1. Ett okänt ankare ger null och behandlas som
76
+ * icke-angränsande, så en oläslig inringning aldrig kan tystas som brus.
77
+ * Ankarnumret är INTE en position: A17 ligger mellan A12 och A13 (#506).
78
+ */
79
+ const position = (v) => (v === null || v === undefined ? -1 : ankarPosition(v))
63
80
  /**
64
81
  * Inringningarna får toleransbehandling bara när de faktiskt pekar på samma del
65
82
  * av skalan: överlappande eller angränsande band. Två körningar som landar på
@@ -68,11 +85,29 @@ const ankarNr = (v) => {
68
85
  * densamma.
69
86
  */
70
87
  const nastanSammaBand = (fa, ta, fb, tb) => {
71
- const [l1, h1, l2, h2] = [ankarNr(fa), ankarNr(ta), ankarNr(fb), ankarNr(tb)]
88
+ const [l1, h1, l2, h2] = [position(fa), position(ta), position(fb), position(tb)]
72
89
  if ([l1, h1, l2, h2].some((n) => n === null)) return false
73
90
  return l1 <= h2 + 1 && l2 <= h1 + 1
74
91
  }
75
92
 
93
+ /**
94
+ * Ankare → relation ur bracket.steps. Saknat, icke-array eller element utan
95
+ * sträng-anchor/relation läses som tomt: äldre arkiv har andra stegformer och
96
+ * ska fortfarande gå att jämföra på poäng och band. Dubblett av samma ankare
97
+ * inom en körning: sista vinner.
98
+ */
99
+ const stegRelationer = (bracket) => {
100
+ const out = new Map()
101
+ const steps = bracket?.steps
102
+ if (!Array.isArray(steps)) return out
103
+ for (const s of steps) {
104
+ if (!s || typeof s !== 'object') continue
105
+ if (typeof s.anchor !== 'string' || typeof s.relation !== 'string') continue
106
+ out.set(s.anchor, s.relation)
107
+ }
108
+ return out
109
+ }
110
+
76
111
  const tolerans = []
77
112
  // Konfidensordet (hog/medel/lag) är en självrapporterad etikett. Den visas
78
113
  // alltid i utskriften, men fäller aldrig ensam ett par vars poäng ligger inom
@@ -94,13 +129,84 @@ if (a.outcome === 'POANG' && b.outcome === 'POANG') {
94
129
  if (fa !== fb) ankarDiffs.push(`bracket.floor.anchor: ${show(fa)} ↔ ${show(fb)}`)
95
130
  if (ta !== tb) ankarDiffs.push(`bracket.ceiling.anchor: ${show(ta)} ↔ ${show(tb)}`)
96
131
  ;(inomTolerans && nastanSammaBand(fa, ta, fb, tb) ? tolerans : diffs).push(...ankarDiffs)
132
+ // Stegdiff (beslut D, ADR-0107): samma inre ankare läst åt motsatt håll är
133
+ // oenighet om skalan, inte prisbrus, så raden går alltid till diffs. Golv och
134
+ // tak i någon av körningarna hoppas över: vid angränsande band flippar det
135
+ // delade gränsankaret av konstruktion (posten är större än A12 som golv,
136
+ // mindre än A12 som tak), och det fallet syns redan som floor/ceiling-rad
137
+ // under angränsningstoleransen (ADR-0078). Stigande ankarordning gör
138
+ // utskriften deterministisk oavsett i vilken ordning agenten skrev stegen.
139
+ const granser = new Set([fa, fb, ta, tb])
140
+ const stegA = stegRelationer(a.bracket)
141
+ const stegB = stegRelationer(b.bracket)
142
+ const flippar = [...stegA.keys()]
143
+ .filter((k) => stegB.has(k) && !granser.has(k) && stegA.get(k) !== stegB.get(k))
144
+ .sort((x, y) => (ankarNr(x) ?? Infinity) - (ankarNr(y) ?? Infinity) || x.localeCompare(y))
145
+ for (const k of flippar)
146
+ diffs.push(`bracket.steps[${k}].relation: ${show(stegA.get(k))} ↔ ${show(stegB.get(k))}`)
147
+ // Kundnära fält utanför huvudpoängen (#509). type är en självrapporterad
148
+ // etikett som loggas för mätning: toleransrad, fäller aldrig ensam.
149
+ // conditions är kundens kontraktsvillkor: ett villkor som finns i ena
150
+ // körningen men inte i den andra är oenighet, inte brus. alternatives
151
+ // matchas per normaliserad label och diffas under samma toleransregel som
152
+ // huvudpoängen; ett alternativ som bara finns i ena körningen visas som
153
+ // toleransrad så granskaren ser det utan att ett par som är ense om priset
154
+ // faller.
155
+ if ((a.type ?? null) !== (b.type ?? null))
156
+ tolerans.push(`type: ${show(a.type)} ↔ ${show(b.type)}`)
157
+ const antal = (v) => (Array.isArray(v) ? v.length : 0)
158
+ if (antal(a.conditions) !== antal(b.conditions))
159
+ diffs.push(`conditions.length: ${antal(a.conditions)} ↔ ${antal(b.conditions)}`)
160
+ const altNyckel = (x) => norm(x.label).toLowerCase()
161
+ const alternativ = (doc) =>
162
+ new Map(
163
+ (Array.isArray(doc.alternatives) ? doc.alternatives : [])
164
+ .filter((x) => x && typeof x === 'object' && typeof x.label === 'string')
165
+ .map((x) => [altNyckel(x), x]),
166
+ )
167
+ const altA = alternativ(a)
168
+ const altB = alternativ(b)
169
+ for (const [key, x] of altA) {
170
+ const y = altB.get(key)
171
+ const label = norm(x.label)
172
+ if (!y) {
173
+ tolerans.push(`alternatives[${JSON.stringify(label)}]: bara i körning 1`)
174
+ continue
175
+ }
176
+ if (x.score === y.score) continue
177
+ const d = Math.abs(x.score - y.score)
178
+ const rad = `alternatives[${JSON.stringify(label)}].score: ${show(x.score)} ↔ ${show(y.score)}`
179
+ ;(Number.isFinite(d) && d <= TOLERANS ? tolerans : diffs).push(rad)
180
+ }
181
+ for (const [key, y] of altB)
182
+ if (!altA.has(key))
183
+ tolerans.push(`alternatives[${JSON.stringify(norm(y.label))}]: bara i körning 2`)
184
+ } else if (a.outcome === 'OVER_SKALAN' && b.outcome === 'OVER_SKALAN') {
185
+ // ADR-0116: inget tak är prisbärande. Utfallet är lika, golvet avgör.
186
+ const fa = a.bracket?.floor?.anchor ?? null
187
+ const fb = b.bracket?.floor?.anchor ?? null
188
+ if (fa !== fb) diffs.push(`bracket.floor.anchor: ${show(fa)} ↔ ${show(fb)}`)
189
+ if (konfDiff) tolerans.push(konfDiff)
97
190
  } else if (konfDiff) {
98
191
  diffs.push(konfDiff)
99
192
  }
100
193
 
194
+ // Täckningspost (ADR-0119): fältets närvaro är oenighet om vad som ska
195
+ // prissättas, innehållet jämförs inte.
196
+ const harCp = (d) => d.coverage_post !== undefined && d.coverage_post !== null
197
+ if (harCp(a) !== harCp(b))
198
+ diffs.push(`coverage_post: ${harCp(a) ? 'finns' : 'saknas'} ↔ ${harCp(b) ? 'finns' : 'saknas'}`)
199
+
101
200
  if (diffs.length === 0 && tolerans.length > 0) {
102
- const winner = b.score > a.score ? 2 : 1
103
- console.log(`LIKA_TOLERANS: högsta gäller ${Math.max(a.score, b.score)} (körning ${winner})`)
201
+ // OVER_SKALAN bär score: null (ADR-0116), "högsta poängen" är obegripligt
202
+ // för ett par utan tal. Golvet redan lika (annars hade grenen ovan diffat),
203
+ // så bara konfidensen skiljer; körning 1 vinner deterministiskt.
204
+ if (a.outcome === 'OVER_SKALAN' && b.outcome === 'OVER_SKALAN') {
205
+ console.log('LIKA_TOLERANS: samma golv, konfidens skiljer (körning 1)')
206
+ } else {
207
+ const winner = b.score > a.score ? 2 : 1
208
+ console.log(`LIKA_TOLERANS: högsta gäller ${Math.max(a.score, b.score)} (körning ${winner})`)
209
+ }
104
210
  for (const t of tolerans) console.log(`- ${t}`)
105
211
  process.exit(0)
106
212
  }
@@ -6,17 +6,52 @@
6
6
  * ska fortsätta gå att kontrollera. Fångar det starka modeller ändå slarvar
7
7
  * med i lång kontext:
8
8
  * parafraserade "ordagranna" citat, läckta protokolltermer i kundtext, brutna
9
- * enum- och upplösningsregler. Bedömer aldrig innehållets rimlighet — bara
10
- * kontraktet. Vid BROTT: dispatcha agenten om EN gång med felraderna, sedan
9
+ * enum- och upplösningsregler. Ett svartlistat ord som står i specen (kundens
10
+ * eget dokument; analysen är Haus-intern och friar inget) är kundvokabulär
11
+ * och fälls inte (#504). Bedömer aldrig innehållets rimlighet
12
+ * — bara kontraktet. Vid BROTT: dispatcha agenten om EN gång med felraderna, sedan
11
13
  * stopp. VARNING är heuristik (t.ex. ankar-id som krockar med pappersformat) —
12
14
  * visas, stoppar inte.
13
15
  *
16
+ * Stegschemat: `bracket.steps` (och `alternatives[i].bracket.steps`) är en
17
+ * lista av objekt med exakt nycklarna `anchor` (A<n>), `relation`
18
+ * (storre/mindre/lika) och `grund` (icke-tom). Egna nycklar är brott, som i
19
+ * kuvertet. Äldre arkiv med strängsteg läses fortfarande av report.mjs;
20
+ * bara färska körningar går genom den här kontrollen.
21
+ *
22
+ * Inringningens konsistens (ADR-0115): golv före tak, between två angränsande
23
+ * ankare inuti [golv, tak] (får sammanfalla med dem), poängen mellan
24
+ * between-paret, och varje stegs relation mot poängen. Poäng PÅ ett ankare
25
+ * trots storre/mindre är VARNING bara när between-paret inte rymmer någon
26
+ * poäng emellan; annars BROTT. Ordningen kommer från lib.mjs ANKARE, aldrig
27
+ * ur ankarnumret.
28
+ *
29
+ * Motsägelsegrinden (ADR-0106): när impact.md ges och analysens rad
30
+ * `**Avvikelse mot specen:**` bär etiketten `motsägelse` är TILLBAKA_TILL_SPEC
31
+ * det enda giltiga utfallet. Varje annat outcome är brott. Saknad etikett
32
+ * fäller inget här, precheck äger etikettkravet.
33
+ *
14
34
  * Användning: node contract-check.mjs <run.json> <spec.md> [impact.md]
15
35
  * Exit 0 = ok, 1 = kontraktsbrott (listade), 2 = användningsfel.
16
36
  */
17
37
  import { readFileSync } from 'node:fs'
18
38
 
19
- import { refDriftWarning, norm, quoteOk, CLASS_UTFALL, SCORE_OUTCOMES } from './lib.mjs'
39
+ import {
40
+ refDriftWarning,
41
+ norm,
42
+ quoteOk,
43
+ avvikelseEtikett,
44
+ CLASS_UTFALL,
45
+ SCORE_OUTCOMES,
46
+ CUSTOMER_BLACKLIST,
47
+ MODES,
48
+ STEP_RELATIONS,
49
+ KONFIDENS,
50
+ ankarPosition,
51
+ ankarPoang,
52
+ toppAnkare,
53
+ TYPER,
54
+ } from './lib.mjs'
20
55
 
21
56
  const argv = process.argv.slice(2)
22
57
  let expectId = null
@@ -38,6 +73,20 @@ if (idFlag !== -1) {
38
73
  expectId = v
39
74
  argv.splice(idFlag, 2)
40
75
  }
76
+ // --state pekar ut den buntdels/split-statefil (#503) som avgör om posten är
77
+ // en split-del (split.part) och därmed får sätta OVER_SKALAN. Utan värde är
78
+ // det, som --expect-id ovan, nästan säkert ett dispatcherfel: stanna hårt.
79
+ let statePath = null
80
+ const stateFlag = argv.indexOf('--state')
81
+ if (stateFlag !== -1) {
82
+ const v = argv[stateFlag + 1]
83
+ if (v === undefined || v.startsWith('--')) {
84
+ console.error('--state kräver ett värde')
85
+ process.exit(2)
86
+ }
87
+ statePath = v
88
+ argv.splice(stateFlag, 2)
89
+ }
41
90
  // En felstavad flaggform (t.ex. `--expectid`, bindestrecket i "expect-id"
42
91
  // tappat) matchar aldrig `--expect-id` ovan och blir kvar i `argv` som ett
43
92
  // obehandlat argument. Utan den här kontrollen landade den tyst som
@@ -48,17 +97,31 @@ if (idFlag !== -1) {
48
97
  // användningsfel, aldrig en gissning om vad anroparen menade.
49
98
  const okandFlagga = argv.find((a) => a.startsWith('--'))
50
99
  if (okandFlagga !== undefined) {
51
- console.error(`okänd flagga: ${okandFlagga} (giltig: --expect-id <id>)`)
100
+ console.error(`okänd flagga: ${okandFlagga} (giltiga: --expect-id <id>, --state <state.json>)`)
52
101
  process.exit(2)
53
102
  }
54
103
  const [jsonPath, specPath, impactPath] = argv
55
104
  if (!jsonPath || !specPath) {
56
105
  console.error(
57
- 'användning: node contract-check.mjs <run.json> <spec.md> [impact.md] [--expect-id <id>]',
106
+ 'användning: node contract-check.mjs <run.json> <spec.md> [impact.md] [--expect-id <id>] [--state <state.json>]',
58
107
  )
59
108
  process.exit(2)
60
109
  }
61
110
 
111
+ // Split-status (#503): null när --state inte gavs (OVER_SKALAN blir då en
112
+ // VARNING, inte ett BROTT), annars true/false ur split.part i statefilen. En
113
+ // oläsbar statefil är ett dispatcherfel, inte agentens brott.
114
+ let splitDel = null
115
+ if (statePath !== null) {
116
+ try {
117
+ const st = JSON.parse(readFileSync(statePath, 'utf8'))
118
+ splitDel = st?.split?.part != null
119
+ } catch (e) {
120
+ console.error(`kunde inte läsa ${statePath}: ${e.message}`)
121
+ process.exit(2)
122
+ }
123
+ }
124
+
62
125
  const problems = []
63
126
  const warnings = []
64
127
 
@@ -71,7 +134,7 @@ const warnings = []
71
134
  // referensfil att drifta mot.
72
135
  const EXPECTED_REFS = {
73
136
  'request-score/references/poangskala.md':
74
- 'cb5a6c221b5d39c445ca31a427fd3f96c648388ac5441c22ea2ea5f72e1f8e31',
137
+ '810a71f2767173bdc1747e932f466f3fc25cb412b84f61fab0ca0894ff80438a',
75
138
  }
76
139
  for (const [rel, expected] of Object.entries(EXPECTED_REFS)) {
77
140
  const w = refDriftWarning(rel, expected)
@@ -87,10 +150,16 @@ try {
87
150
  // Oläsbart underlag är dispatcherns fel (exit 2), aldrig ett kontraktsbrott —
88
151
  // exit 1 skulle trigga en meningslös och dyr omdispatch av agenten.
89
152
  let source
153
+ // Analysens råtext hålls isär från det normaliserade underlaget: etiketten på
154
+ // avvikelseraden läses ur den (motsägelsegrinden nedan), och norm() skulle
155
+ // kunna vika radbrytningar som rad-regexen i avvikelseEtikett förlitar sig på.
156
+ let impactText = null
157
+ let kundUnderlag
90
158
  try {
91
- source = norm(
92
- readFileSync(specPath, 'utf8') + '\n' + (impactPath ? readFileSync(impactPath, 'utf8') : ''),
93
- )
159
+ const specText = readFileSync(specPath, 'utf8')
160
+ if (impactPath) impactText = readFileSync(impactPath, 'utf8')
161
+ source = norm(specText + '\n' + (impactText ?? ''))
162
+ kundUnderlag = norm(specText)
94
163
  } catch (e) {
95
164
  console.error(`kunde inte läsa underlaget: ${e.message}`)
96
165
  process.exit(2)
@@ -108,31 +177,8 @@ const collectStrings = (v) => {
108
177
  return []
109
178
  }
110
179
 
111
- // Protokollmekanik som aldrig får nå kundtext. Klass/klassas är kundspråk
112
- // (kunden ser Class-fältet) och KONCEPT/DIAGNOS är legitima kundutfall — de
113
- // står medvetet INTE här. Poäng och skalans mekanik är interna. Gränserna är
114
- // Unicode-medvetna (JS \b bryter vid å/ä/ö), och "protokoll" är snävat så att
115
- // vardagssvenska som "protokollförs" går fri medan klassningsprotokollet fångas.
116
- const B = (pat) => new RegExp(`(?<![\\p{L}\\d_])(?:${pat})`, 'iu')
117
- const CUSTOMER_BLACKLIST = [
118
- B('poäng\\p{L}*'),
119
- B('basklass\\p{L}*'),
120
- B('bump\\p{L}*'),
121
- B('ankar\\p{L}*'),
122
- B('referensuppdrag\\p{L}*'),
123
- B('inringning\\p{L}*'),
124
- B('konfidens\\p{L}*'),
125
- // Substantivformer av skalan är mekanik; verbet "skalar" (skalbarhet) är
126
- // legitim kundsvenska och går fritt.
127
- B('skal(?:a|an|ans|or|orna)(?!\\p{L})'),
128
- B('protokoll(?:et|en|s)?(?!\\p{L})'),
129
- /klassningsprotokoll/iu,
130
- B('blast\\s*radius'),
131
- B('touchpoint\\p{L}*'),
132
- // u-flaggan är obligatorisk: utan den är \p{L} i lookaround teckenklassen
133
- // "p{L" och "era B2B-kunder" blir ett falskt BROTT.
134
- /(?<![\p{L}\d_])[SB][1-3](?![\p{L}\d_])/u,
135
- ]
180
+ // CUSTOMER_BLACKLIST bor i lib.mjs (delad med kontroll.mjs, som avgör om ett
181
+ // brott är ett rent kundtextbrott innan en fältomskrivning kan täcka det).
136
182
  // Ankar-id (A1–A17) kolliderar strukturellt med pappersformat (A3/A4/A5) och
137
183
  // går inte att regexa isär — heuristik, alltså varning i stället för brott.
138
184
  const ANCHOR_RE = /(?<![\p{L}\d_])A(?:1[0-7]|[1-9])(?![\p{L}\d_])/u
@@ -168,12 +214,24 @@ const ANCHOR_RE = /(?<![\p{L}\d_])A(?:1[0-7]|[1-9])(?![\p{L}\d_])/u
168
214
  const IDIOM_ALLOW = [/hela\s+poängen\s+(?:med|i)\b(?![^.!?]*\d)/giu, /poängen\s+är\s+att\b/giu]
169
215
  const maskIdioms = (text) =>
170
216
  IDIOM_ALLOW.reduce((t, re) => t.replace(re, (m) => '�'.repeat(m.length)), text)
217
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
218
+ /**
219
+ * Ett svartlistat ord som redan förekommer som HELT ord i kundens underlag
220
+ * (specen) är kundens vokabulär, inte skalans mekanik, och fälls inte (#504).
221
+ * Analysen (impact.md) är Haus-intern och friar inget: den skrivs av en
222
+ * Haus-agent, inte kunden, så ett protokollord där är inget kundläckage.
223
+ * Kundens sajt hade en blocktyp "anchor" som agenten återgav som
224
+ * "ankartrigger" — ett läckage kan inte komma från kundens egna dokument.
225
+ * Helordsgräns: "ankartrigger" i specen friar inte "ankartriggern".
226
+ */
227
+ const iUnderlaget = (ord) =>
228
+ new RegExp(`(?<![\\p{L}\\d_])${escapeRe(ord)}(?![\\p{L}\\d_])`, 'iu').test(kundUnderlag)
171
229
  const checkCustomerText = (text, field) => {
172
230
  if (typeof text !== 'string') return
173
231
  const masked = maskIdioms(text)
174
232
  for (const re of CUSTOMER_BLACKLIST) {
175
233
  const m = masked.match(re)
176
- if (m) problems.push(`${field}: protokollterm "${m[0]}" i kundtext`)
234
+ if (m && !iUnderlaget(m[0])) problems.push(`${field}: protokollterm "${m[0]}" i kundtext`)
177
235
  }
178
236
  // Ankarvarningen körs mot ORIGINALET, aldrig mot den maskade strängen.
179
237
  // Maskningen bevarar längden men inte innehållet, och varningscitatet ska
@@ -193,7 +251,14 @@ const checkScoreValue = (score, field) => {
193
251
  else if (score < 3 ? Math.round(score * 4) !== score * 4 : !Number.isInteger(score))
194
252
  problems.push(`${field}: ${score} bryter upplösningen (0.25-steg under 3, heltal från 3)`)
195
253
  }
196
- const checkBracket = (b, field) => {
254
+ /** Nästa giltiga poäng över `lo` på skalans upplösning (0,25 under 3, heltal från 3). */
255
+ const nastaSteg = (lo) => (lo < 3 ? lo + 0.25 : lo + 1)
256
+ /** Avrundningskollaps: between-paret rymmer ingen giltig poäng strikt emellan sig
257
+ * (A13/A14 = 12/13, A9/A10 = 4/4). Då landar avrundningen av nödvändighet PÅ
258
+ * ett av ankarna även när posten bedömts större/mindre än det (#502). */
259
+ const kollapsar = (lo, hi) => !(nastaSteg(lo) < hi)
260
+
261
+ const checkBracket = (b, field, score) => {
197
262
  // Under-A1-fallet är kontraktsenligt golvlöst: "Får du inget golv: posten är
198
263
  // mindre än A1 och får A1:s poäng" — då är floor null och taket A1.
199
264
  const floorless = b?.floor == null && b?.ceiling?.anchor === 'A1'
@@ -214,10 +279,118 @@ const checkBracket = (b, field) => {
214
279
  )
215
280
  if (b.steps.length === 0)
216
281
  problems.push(`${field}.steps: tom — inringningen ska redovisa ankarjämförelserna`)
282
+
283
+ // Konsistens (ADR-0115, #502/#496): delarna ska hänga ihop, inte bara finnas.
284
+ // Bara när poängen är ett tal — annars har checkScoreValue redan fällt.
285
+ const harPoang = typeof score === 'number'
286
+ let kollaps = false
287
+ if (floorless) {
288
+ // Kontraktet självt kollapsar hit: posten är MINDRE än A1 men får A1:s
289
+ // poäng, så "mindre än A1" med A1:s poäng är varning. "storre mot A1" är
290
+ // däremot en rak motsägelse av kontraktet (#514 p2), och between prövas
291
+ // mot ankartabellen precis som i den golvade grenen.
292
+ const a1 = ankarPoang('A1')
293
+ if (harPoang && score !== a1)
294
+ problems.push(
295
+ `${field}: golvlös inringning (under A1) kräver A1:s poäng ${a1}, fick ${score}`,
296
+ )
297
+ for (const id of b.between.filter((x) => ankarPosition(x) === null))
298
+ problems.push(`${field}: okänt ankare "${id}" (skalan är A1–A17)`)
299
+ for (const [i, step] of b.steps.entries())
300
+ if (step && step.anchor === 'A1' && step.relation === 'storre')
301
+ problems.push(
302
+ `${field}.steps[${i}]: storre mot A1 i golvlös inringning, posten är per kontrakt mindre än A1`,
303
+ )
304
+ kollaps = true
305
+ } else if (b.between.length === 2) {
306
+ const okanda = [b.floor.anchor, b.ceiling.anchor, ...b.between].filter(
307
+ (id) => ankarPosition(id) === null,
308
+ )
309
+ if (okanda.length > 0) {
310
+ for (const id of okanda) problems.push(`${field}: okänt ankare "${id}" (skalan är A1–A17)`)
311
+ } else {
312
+ const [pf, pc] = [ankarPosition(b.floor.anchor), ankarPosition(b.ceiling.anchor)]
313
+ const [b0, b1] = b.between
314
+ const [p0, p1] = [ankarPosition(b0), ankarPosition(b1)]
315
+ if (pf >= pc)
316
+ problems.push(
317
+ `${field}: golv ${b.floor.anchor} ligger inte före tak ${b.ceiling.anchor} i skalans ordning`,
318
+ )
319
+ if (p1 !== p0 + 1)
320
+ problems.push(
321
+ `${field}.between: ${b0}/${b1} är inte angränsande i skalans ordning (A17 ligger mellan A12 och A13)`,
322
+ )
323
+ else {
324
+ if (p0 < pf || p1 > pc)
325
+ problems.push(
326
+ `${field}.between: ${b0}/${b1} ligger utanför golv ${b.floor.anchor}–tak ${b.ceiling.anchor}`,
327
+ )
328
+ // Bara ett bekräftat angränsande par har ett lo/hi (#514 p3): ett
329
+ // reverserat par gav lo > hi, en vilseledande rad och en falsk kollaps.
330
+ const [lo, hi] = [ankarPoang(b0), ankarPoang(b1)]
331
+ if (harPoang && (score < lo || score > hi))
332
+ problems.push(
333
+ `${field === 'bracket' ? 'score' : field.replace(/\.bracket$/, '.score')}: ${score} ligger utanför between ${b0} (${lo})–${b1} (${hi})`,
334
+ )
335
+ kollaps = kollapsar(lo, hi)
336
+ }
337
+ }
338
+ }
339
+ for (const [i, step] of b.steps.entries())
340
+ checkStep(step, `${field}.steps[${i}]`, harPoang ? score : null, kollaps)
341
+ }
342
+
343
+ /** Ett steg i inringningen: exakt nycklarna anchor/relation/grund, inga egna.
344
+ * Okänd nyckel fälls för sig så omdispatchen ser vad som ska bort, och de
345
+ * tre obligatoriska kontrolleras oberoende av varandra. Med `score` prövas
346
+ * dessutom att relationen stämmer med poängen (ADR-0115). */
347
+ const checkStep = (step, field, score, kollaps) => {
348
+ if (step === null || typeof step !== 'object' || Array.isArray(step)) {
349
+ problems.push(`${field}: inte ett objekt (schemat är {anchor, relation, grund})`)
350
+ return
351
+ }
352
+ for (const key of Object.keys(step))
353
+ if (!STEP_KEYS.includes(key))
354
+ problems.push(
355
+ `${field}: okänd nyckel "${key}" (schemat är exakt ${STEP_KEYS.join('/')}, egna nycklar är förbjudna)`,
356
+ )
357
+ if (step.anchor === undefined) problems.push(`${field}.anchor: saknas`)
358
+ else if (typeof step.anchor !== 'string' || !ANCHOR_ID_RE.test(step.anchor))
359
+ problems.push(`${field}.anchor: "${step.anchor}" är inte ett ankar-id (A<n>)`)
360
+ else if (ankarPoang(step.anchor) === null)
361
+ problems.push(`${field}.anchor: okänt ankare "${step.anchor}" (skalan är A1–A17)`)
362
+ if (step.relation === undefined) problems.push(`${field}.relation: saknas`)
363
+ else if (!STEP_RELATIONS.includes(step.relation))
364
+ problems.push(
365
+ `${field}.relation: ogiltigt värde "${step.relation}" (giltiga: ${STEP_RELATIONS.join(', ')})`,
366
+ )
367
+ if (typeof step.grund !== 'string' || step.grund.trim().length === 0)
368
+ problems.push(`${field}.grund: saknas eller tom sträng`)
369
+
370
+ // Relation mot poäng. "Landar den på samma nivå som ett referensuppdrag, ge
371
+ // den det ankarets poäng rakt av" (SKILL.md steg 4) — då är relationen lika.
372
+ const ap = ankarPoang(step.anchor)
373
+ if (score === null || ap === null || !STEP_RELATIONS.includes(step.relation)) return
374
+ const id = step.anchor
375
+ if (step.relation === 'lika') {
376
+ if (score !== ap) problems.push(`${field}: lika med ${id} (${ap}) men poängen är ${score}`)
377
+ } else if (step.relation === 'storre' && score < ap) {
378
+ problems.push(`${field}: storre mot ${id} (${ap}) men poängen ${score} är lägre`)
379
+ } else if (step.relation === 'mindre' && score > ap) {
380
+ problems.push(`${field}: mindre än ${id} (${ap}) men poängen ${score} är högre`)
381
+ } else if (score === ap) {
382
+ const rad = `${field}: ${step.relation === 'storre' ? 'storre mot' : 'mindre än'} ${id} men poängen ${score} är ${id}:s poäng`
383
+ if (kollaps)
384
+ warnings.push(`${rad} (avrundningskollaps: between-paret rymmer ingen poäng emellan)`)
385
+ else problems.push(`${rad} — relationen ska vara lika, eller poängen flyttas`)
386
+ }
217
387
  }
218
388
 
219
- const MODES = ['pricing', 'guard']
220
389
  const BASKLASS = ['TINY', 'SMALL', 'MEDIUM', 'LARGE', 'OVER']
390
+ // MODES, STEP_RELATIONS och KONFIDENS bor i lib.mjs (delade med kontroll.mjs,
391
+ // som reparerar samma enumer innan dispatch).
392
+ const STEP_KEYS = ['anchor', 'relation', 'grund']
393
+ const ANCHOR_ID_RE = /^A\d+$/
221
394
 
222
395
  // Kuverten är slutna: agenterna får aldrig lägga till egna nycklar ("ordagrant
223
396
  // och orört"). Okänd toppnyckel är ett brott, inte en utökning.
@@ -237,6 +410,7 @@ const ENVELOPE_KEYS = {
237
410
  'alternatives',
238
411
  'dependencies',
239
412
  'notes',
413
+ 'coverage_post',
240
414
  'customer_rationale',
241
415
  'guard_diff',
242
416
  ],
@@ -331,11 +505,19 @@ if (doc.classification !== undefined) {
331
505
  checkEnvelopeKeys('score')
332
506
  if (!MODES.includes(doc.mode)) problems.push(`mode: ogiltigt värde "${doc.mode}"`)
333
507
  if (!OUTCOMES.includes(doc.outcome)) problems.push(`outcome: ogiltigt värde "${doc.outcome}"`)
334
- if (!['hog', 'medel', 'lag'].includes(doc.confidence))
508
+ if (!KONFIDENS.includes(doc.confidence))
335
509
  problems.push(`confidence: ogiltigt värde "${doc.confidence}"`)
510
+ // Typen (#509): loggas för att mäta systematisk felplacering per typ, så
511
+ // värdet måste vara ett av översiktstabellens fem. Tom typ tolereras bara på
512
+ // stopputfall (där finns ingen placering att mäta).
513
+ const typTom = doc.type === undefined || doc.type === null || doc.type === ''
514
+ if (doc.outcome === 'POANG' && typTom)
515
+ problems.push('type: saknas (POANG kräver en av översiktstabellens typer)')
516
+ else if (!typTom && !TYPER.includes(doc.type))
517
+ problems.push(`type: ogiltigt värde "${doc.type}" (giltiga: ${TYPER.join(', ')})`)
336
518
  if (doc.outcome === 'POANG') {
337
519
  checkScoreValue(doc.score, 'score')
338
- checkBracket(doc.bracket, 'bracket')
520
+ checkBracket(doc.bracket, 'bracket', doc.score)
339
521
  if (!Array.isArray(doc.drivers) || doc.drivers.length < 2 || doc.drivers.length > 3)
340
522
  problems.push(
341
523
  `drivers: ${Array.isArray(doc.drivers) ? doc.drivers.length : 'saknas'} (skillen kräver två eller tre faktorer)`,
@@ -343,6 +525,52 @@ if (doc.classification !== undefined) {
343
525
  } else if (doc.score != null) {
344
526
  problems.push(`score: ska vara null när outcome är ${doc.outcome}`)
345
527
  }
528
+ // #503: inget tak är ett prisbärande utfall. OVER_SKALAN får bara sättas på
529
+ // en split-del (skillen steg 2), en buntdel är inte en split-del; och båda
530
+ // inget-tak-utfallen måste ha jämfört posten mot skalans översta ankare
531
+ // innan de ger upp.
532
+ if (doc.outcome === 'OVER_SKALAN') {
533
+ if (splitDel === null)
534
+ warnings.push('OVER_SKALAN: split-status okänd (kör med --state <state.json> för att grinda)')
535
+ else if (!splitDel)
536
+ problems.push(
537
+ 'OVER_SKALAN kräver att posten är en split-del (split.part), en buntdel är inte en split-del: utfallet är DELA_UPP',
538
+ )
539
+ }
540
+ if (doc.outcome === 'DELA_UPP' || doc.outcome === 'OVER_SKALAN') {
541
+ const topp = toppAnkare()
542
+ const steps = Array.isArray(doc.bracket?.steps) ? doc.bracket.steps : []
543
+ if (!steps.some((s) => s && s.anchor === topp))
544
+ problems.push(
545
+ `inget tak kräver en jämförelse mot ${topp} i bracket.steps, annars är taket inte prövat`,
546
+ )
547
+ }
548
+ // Motsägelsegrinden (ADR-0106): analysen har redan sagt att specen och
549
+ // koden inte går ihop, då finns inget att poängsätta och inget att
550
+ // konceptera. Regeln har inga undantag (samma text som i skillen). Utan
551
+ // etikett (äldre analys) fälls inget, det kravet äger precheck.
552
+ if (impactText !== null && avvikelseEtikett(impactText) === 'motsägelse') {
553
+ if (doc.outcome !== 'TILLBAKA_TILL_SPEC')
554
+ problems.push(
555
+ `analysen bär motsägelse mot specen: ${doc.outcome} är inte giltigt, TILLBAKA_TILL_SPEC krävs`,
556
+ )
557
+ }
558
+ // Täckningspost (ADR-0119, #508): antagandet "satt som om tester fanns" är
559
+ // ett strukturerat fält, inte en fritextnot, så det kan bli en egen post.
560
+ if (doc.coverage_post !== undefined) {
561
+ const cp = doc.coverage_post
562
+ if (cp === null || typeof cp !== 'object' || Array.isArray(cp))
563
+ problems.push('coverage_post: inte ett objekt (schemat är {yta, omfattning})')
564
+ else {
565
+ for (const key of Object.keys(cp))
566
+ if (!['yta', 'omfattning'].includes(key))
567
+ problems.push(`coverage_post: okänd nyckel "${key}"`)
568
+ for (const key of ['yta', 'omfattning'])
569
+ if (typeof cp[key] !== 'string' || cp[key].trim() === '')
570
+ problems.push(`coverage_post.${key}: saknas eller tom sträng`)
571
+ if (typeof cp.yta === 'string') checkCustomerText(cp.yta, 'coverage_post.yta')
572
+ }
573
+ }
346
574
  // Ett ankare utan sitt lösta citat betyder att dispatchern hoppat över
347
575
  // resolve-anchors.mjs: dispatcherns fel (exit 2), aldrig agentens brott.
348
576
  for (const [i, d] of (Array.isArray(doc.drivers) ? doc.drivers : []).entries())
@@ -374,7 +602,7 @@ if (doc.classification !== undefined) {
374
602
  checkCustomerText(alt?.adds, `alternatives[${i}].adds`)
375
603
  if (alt?.score != null) {
376
604
  checkScoreValue(alt.score, `alternatives[${i}].score`)
377
- checkBracket(alt?.bracket, `alternatives[${i}].bracket`)
605
+ checkBracket(alt?.bracket, `alternatives[${i}].bracket`, alt.score)
378
606
  }
379
607
  }
380
608
  } else {