@ai-sdlc/orchestrator 0.9.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/__test-helpers/git-env.d.ts +40 -0
  2. package/dist/__test-helpers/git-env.js +73 -0
  3. package/dist/adapters.d.ts +42 -3
  4. package/dist/adapters.js +133 -3
  5. package/dist/admission-composite.d.ts +134 -1
  6. package/dist/admission-composite.js +170 -6
  7. package/dist/admission-enrichment.d.ts +12 -3
  8. package/dist/admission-enrichment.js +71 -10
  9. package/dist/admission-hc.d.ts +20 -1
  10. package/dist/admission-hc.js +12 -1
  11. package/dist/admission-score.d.ts +44 -0
  12. package/dist/admission-score.js +11 -2
  13. package/dist/backlog-adapter.d.ts +45 -0
  14. package/dist/backlog-adapter.js +145 -1
  15. package/dist/calibration.d.ts +81 -0
  16. package/dist/calibration.js +76 -0
  17. package/dist/cli/commands/git-remote.d.ts +25 -0
  18. package/dist/cli/commands/git-remote.js +80 -1
  19. package/dist/cli/commands/init-features.d.ts +507 -0
  20. package/dist/cli/commands/init-features.js +1100 -0
  21. package/dist/cli/commands/init-templates.d.ts +301 -0
  22. package/dist/cli/commands/init-templates.js +1341 -0
  23. package/dist/cli/commands/init.d.ts +67 -0
  24. package/dist/cli/commands/init.js +301 -2
  25. package/dist/cli/commands/run.js +9 -1
  26. package/dist/cli/index.d.ts +11 -0
  27. package/dist/cli/index.js +42 -8
  28. package/dist/compliance/composer.d.ts +79 -0
  29. package/dist/compliance/composer.js +258 -0
  30. package/dist/compliance/errors.d.ts +64 -0
  31. package/dist/compliance/errors.js +85 -0
  32. package/dist/compliance/loader.d.ts +52 -0
  33. package/dist/compliance/loader.js +124 -0
  34. package/dist/compliance/types.d.ts +184 -0
  35. package/dist/compliance/types.js +41 -0
  36. package/dist/compliance-clearance.d.ts +269 -0
  37. package/dist/compliance-clearance.js +269 -0
  38. package/dist/config.js +17 -0
  39. package/dist/cost-tracker.d.ts +22 -0
  40. package/dist/cost-tracker.js +41 -0
  41. package/dist/database/adapters/external.js +5 -1
  42. package/dist/design-authority.d.ts +13 -0
  43. package/dist/design-authority.js +13 -0
  44. package/dist/embedding/adapters/openai-text-embedding-3-small.d.ts +71 -0
  45. package/dist/embedding/adapters/openai-text-embedding-3-small.js +190 -0
  46. package/dist/embedding/consumers/tessellation-drift.d.ts +74 -0
  47. package/dist/embedding/consumers/tessellation-drift.js +76 -0
  48. package/dist/embedding/cross-provider.d.ts +78 -0
  49. package/dist/embedding/cross-provider.js +75 -0
  50. package/dist/embedding/deprecation.d.ts +151 -0
  51. package/dist/embedding/deprecation.js +229 -0
  52. package/dist/embedding/errors.d.ts +90 -0
  53. package/dist/embedding/errors.js +150 -0
  54. package/dist/embedding/index.d.ts +29 -0
  55. package/dist/embedding/index.js +24 -0
  56. package/dist/embedding/pipeline-load.d.ts +146 -0
  57. package/dist/embedding/pipeline-load.js +178 -0
  58. package/dist/embedding/registry.d.ts +45 -0
  59. package/dist/embedding/registry.js +61 -0
  60. package/dist/embedding/stale-vector.d.ts +110 -0
  61. package/dist/embedding/stale-vector.js +92 -0
  62. package/dist/embedding/storage/index.d.ts +51 -0
  63. package/dist/embedding/storage/index.js +43 -0
  64. package/dist/embedding/storage/jsonl-backend.d.ts +150 -0
  65. package/dist/embedding/storage/jsonl-backend.js +332 -0
  66. package/dist/embedding/storage/types.d.ts +135 -0
  67. package/dist/embedding/storage/types.js +13 -0
  68. package/dist/embedding/types.d.ts +180 -0
  69. package/dist/embedding/types.js +10 -0
  70. package/dist/execute.d.ts +29 -2
  71. package/dist/execute.js +179 -35
  72. package/dist/index.d.ts +14 -2
  73. package/dist/index.js +19 -2
  74. package/dist/journey/inheritance-validator.d.ts +396 -0
  75. package/dist/journey/inheritance-validator.js +370 -0
  76. package/dist/journey/state-id-drift-rule.d.ts +137 -0
  77. package/dist/journey/state-id-drift-rule.js +245 -0
  78. package/dist/journey-sa2-router.d.ts +395 -0
  79. package/dist/journey-sa2-router.js +308 -0
  80. package/dist/models/classifier.d.ts +3 -1
  81. package/dist/models/classifier.js +61 -5
  82. package/dist/pillar-breakdown.d.ts +23 -1
  83. package/dist/pillar-breakdown.js +9 -0
  84. package/dist/runners/review-agent.js +6 -2
  85. package/dist/runners/runner-registry.d.ts +36 -0
  86. package/dist/runners/runner-registry.js +90 -0
  87. package/dist/runtime/attestations.d.ts +663 -11
  88. package/dist/runtime/attestations.js +971 -24
  89. package/dist/runtime/index.d.ts +1 -1
  90. package/dist/runtime/index.js +1 -1
  91. package/dist/runtime/parallelism-flag.d.ts +21 -3
  92. package/dist/runtime/parallelism-flag.js +27 -6
  93. package/dist/runtime/port-allocator.d.ts +11 -0
  94. package/dist/runtime/port-allocator.js +30 -0
  95. package/dist/sa-scoring/layer3-llm.js +6 -1
  96. package/dist/sa-scoring/revision-proposal-config.d.ts +178 -0
  97. package/dist/sa-scoring/revision-proposal-config.js +198 -0
  98. package/dist/sa-scoring/revision-proposal.d.ts +285 -0
  99. package/dist/sa-scoring/revision-proposal.js +417 -0
  100. package/dist/signal-ingestion/adapters/community-thread.d.ts +43 -0
  101. package/dist/signal-ingestion/adapters/community-thread.js +55 -0
  102. package/dist/signal-ingestion/adapters/in-app-feedback.d.ts +67 -0
  103. package/dist/signal-ingestion/adapters/in-app-feedback.js +51 -0
  104. package/dist/signal-ingestion/adapters/manual.d.ts +78 -0
  105. package/dist/signal-ingestion/adapters/manual.js +112 -0
  106. package/dist/signal-ingestion/adapters/support-ticket.d.ts +47 -0
  107. package/dist/signal-ingestion/adapters/support-ticket.js +51 -0
  108. package/dist/signal-ingestion/classifier.d.ts +205 -0
  109. package/dist/signal-ingestion/classifier.js +494 -0
  110. package/dist/signal-ingestion/clustering-types.d.ts +36 -0
  111. package/dist/signal-ingestion/clustering-types.js +14 -0
  112. package/dist/signal-ingestion/clustering.d.ts +200 -0
  113. package/dist/signal-ingestion/clustering.js +413 -0
  114. package/dist/signal-ingestion/config.d.ts +351 -0
  115. package/dist/signal-ingestion/config.js +587 -0
  116. package/dist/signal-ingestion/d1.d.ts +252 -0
  117. package/dist/signal-ingestion/d1.js +235 -0
  118. package/dist/signal-ingestion/errors.d.ts +73 -0
  119. package/dist/signal-ingestion/errors.js +108 -0
  120. package/dist/signal-ingestion/governance-events.d.ts +181 -0
  121. package/dist/signal-ingestion/governance-events.js +189 -0
  122. package/dist/signal-ingestion/index.d.ts +35 -0
  123. package/dist/signal-ingestion/index.js +53 -0
  124. package/dist/signal-ingestion/manual-share-metric.d.ts +93 -0
  125. package/dist/signal-ingestion/manual-share-metric.js +106 -0
  126. package/dist/signal-ingestion/registry.d.ts +40 -0
  127. package/dist/signal-ingestion/registry.js +137 -0
  128. package/dist/signal-ingestion/residency.d.ts +227 -0
  129. package/dist/signal-ingestion/residency.js +238 -0
  130. package/dist/signal-ingestion/significance.d.ts +554 -0
  131. package/dist/signal-ingestion/significance.js +555 -0
  132. package/dist/signal-ingestion/types.d.ts +191 -0
  133. package/dist/signal-ingestion/types.js +8 -0
  134. package/dist/substrate/drift-composition.d.ts +270 -0
  135. package/dist/substrate/drift-composition.js +306 -0
  136. package/dist/substrate/drift-tui-surface.d.ts +61 -0
  137. package/dist/substrate/drift-tui-surface.js +102 -0
  138. package/dist/substrate/identity-class.d.ts +176 -0
  139. package/dist/substrate/identity-class.js +201 -0
  140. package/dist/tessellation/cross-soul-provenance-rule.d.ts +133 -0
  141. package/dist/tessellation/cross-soul-provenance-rule.js +171 -0
  142. package/dist/tessellation/inter-soul-embedding-distance-rule.d.ts +61 -0
  143. package/dist/tessellation/inter-soul-embedding-distance-rule.js +67 -0
  144. package/dist/tessellation/rule-registry.d.ts +269 -0
  145. package/dist/tessellation/rule-registry.js +92 -0
  146. package/dist/tessellation/soul-slug-ast-scan-rule.d.ts +90 -0
  147. package/dist/tessellation/soul-slug-ast-scan-rule.js +158 -0
  148. package/dist/tessellation-admission.d.ts +162 -0
  149. package/dist/tessellation-admission.js +146 -0
  150. package/dist/tessellation-drift.d.ts +246 -0
  151. package/dist/tessellation-drift.js +250 -0
  152. package/dist/validate-config.js +13 -0
  153. package/dist/validate-issue.js +2 -2
  154. package/dist/variant/cardinality-activation.d.ts +126 -0
  155. package/dist/variant/cardinality-activation.js +101 -0
  156. package/dist/variant/deprecation-lifecycle.d.ts +184 -0
  157. package/dist/variant/deprecation-lifecycle.js +208 -0
  158. package/dist/variant/drift-extension.d.ts +136 -0
  159. package/dist/variant/drift-extension.js +164 -0
  160. package/dist/variant/engineering-review.d.ts +185 -0
  161. package/dist/variant/engineering-review.js +142 -0
  162. package/dist/variant/index.d.ts +32 -0
  163. package/dist/variant/index.js +32 -0
  164. package/dist/variant/inheritance-validator.d.ts +165 -0
  165. package/dist/variant/inheritance-validator.js +139 -0
  166. package/dist/variant/internal-adopter/index.d.ts +11 -0
  167. package/dist/variant/internal-adopter/index.js +10 -0
  168. package/dist/variant/internal-adopter/products.d.ts +156 -0
  169. package/dist/variant/internal-adopter/products.js +366 -0
  170. package/dist/variant-admission.d.ts +316 -0
  171. package/dist/variant-admission.js +247 -0
  172. package/package.json +11 -8
@@ -0,0 +1,494 @@
1
+ /**
2
+ * RFC-0030 Phase 2 — Signal classifier.
3
+ *
4
+ * Classifies raw signals on three deterministic axes and applies the
5
+ * English-only language gate (OQ-13.2 resolution):
6
+ *
7
+ * 1. **Tier** — enterprise / mid / smb / free / churned (metadata-driven).
8
+ * Inference order: adapter-provided `customerTier` → customerId lookup →
9
+ * adapter's `defaultTier` (mapped to smb for Tier 1, free for Tier 2).
10
+ *
11
+ * 2. **ICP resonance** — strong / partial / weak (BM25 default; embedding
12
+ * when RFC-0019 adapter is configured — currently always BM25 in v1).
13
+ * For v1 the classifier receives an explicit `icpSegments` list from the
14
+ * caller. BM25 matching measures how closely the signal payload matches
15
+ * the declared ICP segments.
16
+ *
17
+ * 3. **Recency decay** — exponential decay: exp(-age_days × ln(2) / half_life).
18
+ * Applied at scoring time so the pipeline doesn't need to re-compute as
19
+ * time passes (age is passed as input). Half-life is read from config
20
+ * (default 30 days).
21
+ *
22
+ * **Language gate** (OQ-13.2 v0.3 re-walkthrough): non-accepted-language signal
23
+ * payloads are detected via the `franc` library (deterministic; <10ms per
24
+ * signal; JS-native; MIT-licensed; 95%+ accuracy on text >50 chars; returns
25
+ * ISO 639-3 codes). Signals in unsupported languages are dropped and logged as
26
+ * `Decision: signal-language-unsupported`. Per-org `acceptedLanguages` config
27
+ * is respected (default `['en']`); multi-language opt-in (e.g. `['en', 'fr',
28
+ * 'es']`) accepts those languages knowingly — adopters take on documented BM25
29
+ * quality degradation (~15-30% precision drop without per-language stopwords)
30
+ * in exchange for non-English signal coverage.
31
+ *
32
+ * All multipliers and weights are read from `SignalIngestionConfig`; they are
33
+ * NOT hardcoded, satisfying AC #6.
34
+ *
35
+ * @module signal-ingestion/classifier
36
+ */
37
+ import { franc, francAll } from 'franc';
38
+ import { DEFAULT_SIGNAL_INGESTION_CONFIG } from './config.js';
39
+ // ── Main entry point ────────────────────────────────────────────────────────
40
+ /**
41
+ * Classify a batch of raw signals.
42
+ *
43
+ * Per RFC-0030 §6 inference order for tier:
44
+ * 1. `signal.customerTier` (adapter-provided structured field)
45
+ * 2. `tierRegistry.resolve(signal.customerId)` (registry lookup)
46
+ * 3. Adapter `defaultTier` (mapped: Tier 1 → smb, Tier 2 → free)
47
+ *
48
+ * Per RFC-0030 §13.2: non-accepted-language signals are dropped and
49
+ * returned in `languageDecisions`.
50
+ */
51
+ export function classifySignals(signals, options = {}) {
52
+ const config = options.config ?? DEFAULT_SIGNAL_INGESTION_CONFIG;
53
+ const asOf = options.asOf ?? new Date();
54
+ const icpSegments = options.icpSegments ?? [];
55
+ const tierRegistry = options.tierRegistry;
56
+ const adapterTiers = options.adapterTiers ?? new Map();
57
+ const acceptedLanguages = config.acceptedLanguages;
58
+ const languageDetection = config.languageDetection;
59
+ const classified = [];
60
+ const languageDecisions = [];
61
+ for (const signal of signals) {
62
+ // Language gate — drop non-accepted-language signals
63
+ const langCheck = checkLanguage(signal.payload, acceptedLanguages, languageDetection);
64
+ if (!langCheck.accepted) {
65
+ languageDecisions.push({
66
+ type: 'Decision',
67
+ decision: 'signal-language-unsupported',
68
+ sourceId: signal.sourceId,
69
+ detectedLanguage: langCheck.detectedLanguage,
70
+ detectedScript: langCheck.detectedScript,
71
+ acceptedLanguages,
72
+ message: `Signal ${signal.sourceId} dropped: detected language '${langCheck.detectedLanguage}' (script '${langCheck.detectedScript}') not in accepted languages ${JSON.stringify(acceptedLanguages)}`,
73
+ });
74
+ continue;
75
+ }
76
+ const customerTier = resolveCustomerTier(signal, tierRegistry, adapterTiers);
77
+ const icpResonance = resolveIcpResonance(signal.payload, icpSegments);
78
+ const recencyDecay = computeRecencyDecay(signal.sourceTimestamp, asOf, config.recencyHalfLifeDays);
79
+ const tierMultiplier = config.tierMultipliers[customerTier];
80
+ const icpResonanceWeight = config.icpResonanceWeights[icpResonance];
81
+ // Resolve adapter tier for base weight (Phase 4 will refine with significance threshold)
82
+ const sourceAdapterTier = adapterTiers.get(signal.metadata?.['adapterName']) ?? 1;
83
+ const baseWeight = sourceAdapterTier === 1 ? 1.0 : 0.3;
84
+ classified.push({
85
+ signal,
86
+ customerTier,
87
+ icpResonance,
88
+ recencyDecay,
89
+ tierMultiplier,
90
+ icpResonanceWeight,
91
+ baseWeight,
92
+ });
93
+ }
94
+ return { classified, languageDecisions };
95
+ }
96
+ // ── Tier resolution ─────────────────────────────────────────────────────────
97
+ /**
98
+ * Resolve customer tier per RFC-0030 §6.1 inference order:
99
+ * 1. `signal.customerTier`
100
+ * 2. `tierRegistry.resolve(signal.customerId)`
101
+ * 3. Adapter defaultTier → smb (Tier 1) or free (Tier 2)
102
+ */
103
+ export function resolveCustomerTier(signal, tierRegistry, adapterTiers) {
104
+ // Step 1: adapter-provided structured tier
105
+ if (signal.customerTier !== undefined) {
106
+ return signal.customerTier;
107
+ }
108
+ // Step 2: customerId registry lookup
109
+ if (tierRegistry && signal.customerId !== undefined) {
110
+ const resolved = tierRegistry.resolve(signal.customerId);
111
+ if (resolved !== undefined)
112
+ return resolved;
113
+ }
114
+ // Step 3: adapter defaultTier mapping
115
+ const adapterName = signal.metadata?.['adapterName'];
116
+ const signalTier = adapterName ? (adapterTiers?.get(adapterName) ?? 1) : 1;
117
+ // Tier 1 adapters (support tickets, CRM) → smb as baseline customer tier
118
+ // Tier 2 adapters (community, competitive) → free as baseline
119
+ return signalTier === 1 ? 'smb' : 'free';
120
+ }
121
+ // ── ICP resonance (BM25) ────────────────────────────────────────────────────
122
+ /**
123
+ * Classify ICP resonance for a signal payload against the declared ICP
124
+ * segment descriptors using BM25 similarity.
125
+ *
126
+ * When `icpSegments` is empty, returns `partial` (no basis for strong/weak).
127
+ *
128
+ * BM25 parameters (k1=1.5, b=0.75) are standard.
129
+ * Score mapping:
130
+ * - ≥ 0.5 → strong
131
+ * - ≥ 0.15 → partial
132
+ * - < 0.15 → weak
133
+ */
134
+ export function resolveIcpResonance(payload, icpSegments) {
135
+ if (icpSegments.length === 0)
136
+ return 'partial';
137
+ const score = bm25MaxScore(payload, icpSegments);
138
+ if (score >= 0.5)
139
+ return 'strong';
140
+ if (score >= 0.15)
141
+ return 'partial';
142
+ return 'weak';
143
+ }
144
+ /**
145
+ * Compute the BM25-based ICP match score between a signal payload and the
146
+ * ICP segment corpus.
147
+ *
148
+ * **Scoring direction**: ICP segments are the "documents"; the payload tokens
149
+ * are the "query". We score each ICP segment against the payload terms, then
150
+ * take the maximum across segments. This measures how closely the signal
151
+ * payload covers the ICP segment vocabulary — the deterministic-first
152
+ * approach per RFC-0029 Principle 2.
153
+ *
154
+ * **Normalisation**: we compute the self-score of each ICP segment (score
155
+ * when the document perfectly matches the query — i.e., we pretend the
156
+ * segment itself is the payload) and use the maximum self-score as the
157
+ * denominator. This produces a well-bounded [0, 1] score.
158
+ *
159
+ * Implementation: simplified BM25 without external deps so the orchestrator
160
+ * stays self-contained.
161
+ */
162
+ function bm25MaxScore(payload, corpus) {
163
+ const K1 = 1.5;
164
+ const B = 0.75;
165
+ const payloadTokens = tokenize(payload);
166
+ if (payloadTokens.length === 0)
167
+ return 0;
168
+ // Build term frequency maps for both the corpus docs and the payload
169
+ const corpusTfs = corpus.map((doc) => termFrequency(tokenize(doc)));
170
+ const avgDocLen = corpus.reduce((sum, doc) => sum + tokenize(doc).length, 0) / corpus.length;
171
+ const N = corpus.length;
172
+ /**
173
+ * IDF of a term over the ICP corpus.
174
+ * Smoothed to avoid negatives: log((N - df + 0.5) / (df + 0.5) + 1)
175
+ */
176
+ const idf = (term) => {
177
+ const df = corpusTfs.filter((tf) => (tf.get(term) ?? 0) > 0).length;
178
+ return Math.log((N - df + 0.5) / (df + 0.5) + 1);
179
+ };
180
+ /**
181
+ * BM25 score for one ICP segment against a set of query terms.
182
+ * query terms come from the entity we're scoring (payload OR the segment
183
+ * itself for self-score normalisation).
184
+ */
185
+ const scoreSegment = (segTf, queryTerms) => {
186
+ const docLen = Array.from(segTf.values()).reduce((a, b) => a + b, 0);
187
+ let score = 0;
188
+ for (const term of new Set(queryTerms)) {
189
+ const freq = segTf.get(term) ?? 0;
190
+ if (freq === 0)
191
+ continue;
192
+ const termIdf = idf(term);
193
+ const numerator = freq * (K1 + 1);
194
+ const denominator = freq + K1 * (1 - B + B * (docLen / Math.max(avgDocLen, 1)));
195
+ score += termIdf * (numerator / denominator);
196
+ }
197
+ return score;
198
+ };
199
+ // Score each ICP segment using payload tokens as the query
200
+ let maxScore = 0;
201
+ let maxSelfScore = 0;
202
+ for (let i = 0; i < corpus.length; i++) {
203
+ const segTf = corpusTfs[i];
204
+ const segTokens = tokenize(corpus[i]);
205
+ // Self-score: how much would this segment score if queried by its own tokens?
206
+ const selfScore = scoreSegment(segTf, segTokens);
207
+ if (selfScore > maxSelfScore)
208
+ maxSelfScore = selfScore;
209
+ // Payload score: score this segment using the payload tokens
210
+ const payloadScore = scoreSegment(segTf, payloadTokens);
211
+ if (payloadScore > maxScore)
212
+ maxScore = payloadScore;
213
+ }
214
+ if (maxSelfScore <= 0)
215
+ return 0;
216
+ // Normalise by the maximum achievable score (self-match score)
217
+ return Math.min(1, maxScore / maxSelfScore);
218
+ }
219
+ /** Tokenize a string to lowercase alphanumeric tokens, minimum length 2. */
220
+ export function tokenize(text) {
221
+ return text.toLowerCase().match(/[a-z0-9]{2,}/g) ?? [];
222
+ }
223
+ /** Count term frequencies in a token list. */
224
+ function termFrequency(tokens) {
225
+ const tf = new Map();
226
+ for (const t of tokens)
227
+ tf.set(t, (tf.get(t) ?? 0) + 1);
228
+ return tf;
229
+ }
230
+ // ── Recency decay ───────────────────────────────────────────────────────────
231
+ /**
232
+ * Compute exponential recency decay for a signal.
233
+ *
234
+ * `decay = exp(-age_days * ln(2) / halfLifeDays)`
235
+ *
236
+ * At age = halfLifeDays, decay = 0.5.
237
+ * At age = 0, decay = 1.0.
238
+ * Future-dated signals (age < 0) clamp to 1.0.
239
+ */
240
+ export function computeRecencyDecay(sourceTimestamp, asOf, halfLifeDays) {
241
+ if (halfLifeDays <= 0)
242
+ return 1.0;
243
+ const ageDays = (asOf.getTime() - sourceTimestamp.getTime()) / (1000 * 60 * 60 * 24);
244
+ if (ageDays <= 0)
245
+ return 1.0; // future or now → no decay
246
+ return Math.exp((-ageDays * Math.LN2) / halfLifeDays);
247
+ }
248
+ /**
249
+ * Language gate using the `franc` trigram-based detector (RFC-0030 OQ-13.2
250
+ * v0.3 re-walkthrough resolution).
251
+ *
252
+ * `franc` returns ISO 639-3 three-letter codes (`'eng'`, `'cmn'`, `'fra'`,
253
+ * `'spa'`, etc.) or `'und'` for undetermined text (too short / no script
254
+ * match). It is deterministic, MIT-licensed, JS-native (no model download),
255
+ * runs in <10ms per signal, and is 95%+ accurate on text >50 characters.
256
+ *
257
+ * Behaviour:
258
+ * - The language-tag matcher accepts BCP-47 / ISO 639-1 / ISO 639-3 forms in
259
+ * `acceptedLanguages` (e.g. `'en'`, `'eng'`, `'en-US'` all match English).
260
+ * - Empty payloads return `accepted: true` (nothing to gate on; downstream
261
+ * classifier handles empty-payload signals).
262
+ * - When `franc` returns `'und'` (text too short, no script matched, or
263
+ * `library: 'none'`), behaviour is controlled by
264
+ * `languageDetection.onUndetermined` — `'accept'` (default) is conservative
265
+ * because the dominant short-payload case is legitimate (e.g. "Crash on
266
+ * save", "BUG: 500"); `'drop'` is strict.
267
+ * - When `library: 'none'`, the gate is disabled entirely — all signals
268
+ * accepted regardless of `acceptedLanguages`. Useful for testing or for
269
+ * adopters that pre-filter signals upstream.
270
+ * - **Fuzzy acceptance via francAll top-N**: short technical English (under
271
+ * ~50 chars; e.g. "auth login failure when SAML callback returns no nonce")
272
+ * can misdetect as Nordic / Latin-script neighbours (nno, nld, dan) due to
273
+ * uncommon trigrams. To avoid false-drops on legitimate English, we accept
274
+ * when ANY accepted language appears in the top-N candidates with a relative
275
+ * score ≥ `TOP_N_ACCEPT_THRESHOLD` of the top hit. This is the standard
276
+ * franc-misclassification workaround documented in
277
+ * https://github.com/wooorm/franc#data
278
+ */
279
+ function checkLanguage(payload, acceptedLanguages, languageDetection) {
280
+ // Empty payload — nothing to gate on.
281
+ if (!payload || payload.length === 0) {
282
+ return { accepted: true, detectedLanguage: 'und', detectedScript: 'unknown' };
283
+ }
284
+ // `library: 'none'` disables the gate entirely.
285
+ if (languageDetection.library === 'none') {
286
+ return { accepted: true, detectedLanguage: 'und', detectedScript: 'unknown' };
287
+ }
288
+ // Top language for reporting purposes (Decision payload + script hint).
289
+ const detectedLanguage = franc(payload, { minLength: languageDetection.minDetectionLength });
290
+ const detectedScript = scriptHintFor(detectedLanguage);
291
+ // 'und' — text too short OR no script matched — handle per config policy.
292
+ if (detectedLanguage === 'und') {
293
+ if (languageDetection.onUndetermined === 'accept') {
294
+ return { accepted: true, detectedLanguage, detectedScript };
295
+ }
296
+ return { accepted: false, detectedLanguage, detectedScript };
297
+ }
298
+ // Top-of-list match — fast path.
299
+ if (languageMatchesAccepted(detectedLanguage, acceptedLanguages)) {
300
+ return { accepted: true, detectedLanguage, detectedScript };
301
+ }
302
+ // Fuzzy acceptance via script-prefiltered candidate list. `francAll` only
303
+ // returns languages whose SCRIPT matches the input — Chinese text returns
304
+ // only CJK languages; Cyrillic text returns only Cyrillic languages. So if
305
+ // any accepted language appears at all in the candidate list (above a low
306
+ // relative-score floor), the input shares a script family with an accepted
307
+ // language — which is the load-bearing signal we care about. Short technical
308
+ // English may misclassify the TOP slot as nno/dan/nld, but `eng` will still
309
+ // appear in the list with a high relative score because the script
310
+ // (Latin-script) matches. Genuinely-foreign signals (Chinese, Arabic, Hindi)
311
+ // exclude `eng` from the list entirely via the script prefilter.
312
+ const allCandidates = francAll(payload, {
313
+ minLength: languageDetection.minDetectionLength,
314
+ });
315
+ for (const [lang, score] of allCandidates) {
316
+ if (score < FUZZY_ACCEPT_SCORE_FLOOR)
317
+ break;
318
+ if (languageMatchesAccepted(lang, acceptedLanguages)) {
319
+ return { accepted: true, detectedLanguage: lang, detectedScript: scriptHintFor(lang) };
320
+ }
321
+ }
322
+ return { accepted: false, detectedLanguage, detectedScript };
323
+ }
324
+ /**
325
+ * Minimum relative score (normalised against the top hit's 1.0) for a
326
+ * candidate to be considered for fuzzy acceptance. 0.80 is permissive enough
327
+ * to catch English misclassified as Nordic in short technical bug reports
328
+ * (typical eng-score for sub-60-char English is 0.85-0.98) while still
329
+ * excluding genuinely-distant guesses (franc rarely puts distant-script
330
+ * languages above 0.5 because the script prefilter already excludes them).
331
+ *
332
+ * The load-bearing protection against false-accepts of foreign-script text is
333
+ * franc's built-in SCRIPT PREFILTER: Chinese text returns only CJK
334
+ * candidates, Arabic text returns only Arabic candidates, etc. The fuzzy
335
+ * threshold here only matters for Latin-script close-runners-up.
336
+ */
337
+ const FUZZY_ACCEPT_SCORE_FLOOR = 0.8;
338
+ /**
339
+ * Check whether a franc-detected ISO 639-3 code matches any of the
340
+ * adopter-configured accepted-language tags.
341
+ *
342
+ * Accepts the common forms in `acceptedLanguages`:
343
+ * - ISO 639-3 three-letter codes: `'eng'`, `'fra'`, `'spa'`, `'cmn'`, …
344
+ * - ISO 639-1 two-letter codes: `'en'`, `'fr'`, `'es'`, `'zh'`, …
345
+ * - BCP-47 regional tags: `'en-US'`, `'fr-CA'`, `'zh-Hans'`, … (prefix matched
346
+ * against the two-letter base code)
347
+ *
348
+ * The two-letter ↔ three-letter mapping covers the most common languages
349
+ * adopters opt into; uncommon languages can be specified directly in their
350
+ * ISO 639-3 form to bypass the mapping.
351
+ */
352
+ function languageMatchesAccepted(detectedIso6393, acceptedLanguages) {
353
+ for (const raw of acceptedLanguages) {
354
+ const tag = raw.toLowerCase();
355
+ // Exact ISO 639-3 match (e.g. 'eng' === 'eng').
356
+ if (tag === detectedIso6393)
357
+ return true;
358
+ // Two-letter ISO 639-1 (or BCP-47 prefix) match via the mapping table.
359
+ const base = tag.split('-')[0]; // 'en' from 'en-US', 'en' from 'en'
360
+ const mapped = ISO_639_1_TO_639_3[base];
361
+ if (mapped && mapped === detectedIso6393)
362
+ return true;
363
+ }
364
+ return false;
365
+ }
366
+ /**
367
+ * Two-letter ISO 639-1 → three-letter ISO 639-3 mapping for the most common
368
+ * languages adopters opt into. Not exhaustive — uncommon languages should be
369
+ * configured directly in their ISO 639-3 form (e.g. `'tgl'` for Tagalog).
370
+ *
371
+ * Source: ISO 639 standard cross-reference. The list intentionally includes
372
+ * the languages most likely to appear in multi-language customer-support
373
+ * pipelines (top ~30 languages by speaker count plus EU + East-Asian
374
+ * majors).
375
+ */
376
+ const ISO_639_1_TO_639_3 = {
377
+ en: 'eng',
378
+ fr: 'fra',
379
+ es: 'spa',
380
+ de: 'deu',
381
+ it: 'ita',
382
+ pt: 'por',
383
+ nl: 'nld',
384
+ ru: 'rus',
385
+ pl: 'pol',
386
+ uk: 'ukr',
387
+ cs: 'ces',
388
+ sk: 'slk',
389
+ hu: 'hun',
390
+ ro: 'ron',
391
+ bg: 'bul',
392
+ el: 'ell',
393
+ tr: 'tur',
394
+ sv: 'swe',
395
+ no: 'nob',
396
+ da: 'dan',
397
+ fi: 'fin',
398
+ is: 'isl',
399
+ ga: 'gle',
400
+ zh: 'cmn',
401
+ ja: 'jpn',
402
+ ko: 'kor',
403
+ vi: 'vie',
404
+ th: 'tha',
405
+ id: 'ind',
406
+ ms: 'zlm',
407
+ tl: 'tgl',
408
+ hi: 'hin',
409
+ bn: 'ben',
410
+ ta: 'tam',
411
+ te: 'tel',
412
+ ur: 'urd',
413
+ fa: 'pes',
414
+ ar: 'arb',
415
+ he: 'heb',
416
+ sw: 'swh',
417
+ am: 'amh',
418
+ };
419
+ /**
420
+ * Coarse script-family hint for a franc-detected ISO 639-3 code. Returned in
421
+ * `SignalLanguageUnsupportedDecision.detectedScript` for backwards compat with
422
+ * v0.2 dashboards that key on script family rather than language.
423
+ *
424
+ * This is a small lookup table — only covers the languages we expect to
425
+ * surface in customer-signal pipelines. Unknown codes get `'unknown'`.
426
+ */
427
+ function scriptHintFor(iso6393) {
428
+ if (iso6393 === 'und')
429
+ return 'unknown';
430
+ if (CJK_LANGUAGES.has(iso6393))
431
+ return 'cjk';
432
+ if (CYRILLIC_LANGUAGES.has(iso6393))
433
+ return 'cyrillic';
434
+ if (ARABIC_LANGUAGES.has(iso6393))
435
+ return 'arabic';
436
+ if (iso6393 === 'heb' || iso6393 === 'ydd')
437
+ return 'hebrew';
438
+ if (DEVANAGARI_LANGUAGES.has(iso6393))
439
+ return 'devanagari';
440
+ if (iso6393 === 'tha')
441
+ return 'thai';
442
+ if (iso6393 === 'ell')
443
+ return 'greek';
444
+ if (iso6393 === 'hye')
445
+ return 'armenian';
446
+ if (iso6393 === 'kat')
447
+ return 'georgian';
448
+ // Default: assume Latin-script for everything else (most languages franc
449
+ // detects in our coverage are Latin-script — fr/es/de/pt/it/nl/etc.).
450
+ return 'latin';
451
+ }
452
+ const CJK_LANGUAGES = new Set(['cmn', 'jpn', 'kor', 'yue', 'wuu', 'nan', 'hak']);
453
+ const CYRILLIC_LANGUAGES = new Set([
454
+ 'rus',
455
+ 'ukr',
456
+ 'bel',
457
+ 'bul',
458
+ 'mkd',
459
+ 'srp',
460
+ 'tat',
461
+ 'kaz',
462
+ 'kir',
463
+ 'tgk',
464
+ 'mon',
465
+ 'khk',
466
+ ]);
467
+ const ARABIC_LANGUAGES = new Set(['arb', 'pes', 'urd', 'pbu', 'prs', 'ckb', 'uig']);
468
+ const DEVANAGARI_LANGUAGES = new Set([
469
+ 'hin',
470
+ 'mar',
471
+ 'nep',
472
+ 'npi',
473
+ 'mai',
474
+ 'bho',
475
+ 'awa',
476
+ 'mag',
477
+ 'san',
478
+ ]);
479
+ // ── Weight computation (convenience) ─────────────────────────────────────────
480
+ /**
481
+ * Compute the composite signal weight from a `ClassifiedSignal`.
482
+ *
483
+ * `weight = baseWeight × tierMultiplier × icpResonanceWeight × recencyDecay`
484
+ *
485
+ * Note: SA resonance filter (RFC-0030 §9) is applied at the cluster level in
486
+ * Phase 5, not at the signal level here.
487
+ */
488
+ export function computeSignalWeight(classified) {
489
+ return (classified.baseWeight *
490
+ classified.tierMultiplier *
491
+ classified.icpResonanceWeight *
492
+ classified.recencyDecay);
493
+ }
494
+ //# sourceMappingURL=classifier.js.map
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Input shape consumed by `clusterSignals()` per RFC-0030 Phase 3.
3
+ *
4
+ * `ClusteredSignalInput` is a subset of `ClassifiedSignal` (Phase 2 output)
5
+ * carried through the clusterer plus optional adapter-tier metadata used
6
+ * during cluster aggregation. Kept in a separate file so consumers can
7
+ * import the input shape without pulling in the clusterer's full surface
8
+ * (algorithm types, options) — and so the classifier doesn't depend on
9
+ * clustering.ts (avoid circular imports).
10
+ *
11
+ * @module signal-ingestion/clustering-types
12
+ */
13
+ import type { ICPResonance } from './classifier.js';
14
+ import type { CustomerTier, RawSignal, SignalTier } from './types.js';
15
+ /**
16
+ * Per-signal input to the clusterer.
17
+ *
18
+ * A subset of `ClassifiedSignal` (Phase 2) chosen to keep clustering
19
+ * decoupled from Phase 2's full output shape:
20
+ * - `signal`, `customerTier`, `icpResonance`, `recencyDecay` are carried
21
+ * through and surfaced on the resulting `DemandCluster.members` so
22
+ * Phase 4/5 consumers retain access without re-running classification.
23
+ * - `adapterTier` is an OPTIONAL hint that lets Phase 4 sum `tier1` /
24
+ * `tier2` member counts without re-deriving from `signal.metadata`.
25
+ * When absent, the clusterer falls back to `signal.metadata.adapterTier`
26
+ * then to Tier 1 (matches classifier convention).
27
+ */
28
+ export interface ClusteredSignalInput {
29
+ signal: RawSignal;
30
+ customerTier: CustomerTier;
31
+ icpResonance: ICPResonance;
32
+ recencyDecay: number;
33
+ /** Optional adapter tier hint (Tier 1 default when absent). */
34
+ adapterTier?: SignalTier;
35
+ }
36
+ //# sourceMappingURL=clustering-types.d.ts.map
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Input shape consumed by `clusterSignals()` per RFC-0030 Phase 3.
3
+ *
4
+ * `ClusteredSignalInput` is a subset of `ClassifiedSignal` (Phase 2 output)
5
+ * carried through the clusterer plus optional adapter-tier metadata used
6
+ * during cluster aggregation. Kept in a separate file so consumers can
7
+ * import the input shape without pulling in the clusterer's full surface
8
+ * (algorithm types, options) — and so the classifier doesn't depend on
9
+ * clustering.ts (avoid circular imports).
10
+ *
11
+ * @module signal-ingestion/clustering-types
12
+ */
13
+ export {};
14
+ //# sourceMappingURL=clustering-types.js.map