@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.
- package/dist/__test-helpers/git-env.d.ts +40 -0
- package/dist/__test-helpers/git-env.js +73 -0
- package/dist/adapters.d.ts +42 -3
- package/dist/adapters.js +133 -3
- package/dist/admission-composite.d.ts +134 -1
- package/dist/admission-composite.js +170 -6
- package/dist/admission-enrichment.d.ts +12 -3
- package/dist/admission-enrichment.js +71 -10
- package/dist/admission-hc.d.ts +20 -1
- package/dist/admission-hc.js +12 -1
- package/dist/admission-score.d.ts +44 -0
- package/dist/admission-score.js +11 -2
- package/dist/backlog-adapter.d.ts +45 -0
- package/dist/backlog-adapter.js +145 -1
- package/dist/calibration.d.ts +81 -0
- package/dist/calibration.js +76 -0
- package/dist/cli/commands/git-remote.d.ts +25 -0
- package/dist/cli/commands/git-remote.js +80 -1
- package/dist/cli/commands/init-features.d.ts +507 -0
- package/dist/cli/commands/init-features.js +1100 -0
- package/dist/cli/commands/init-templates.d.ts +301 -0
- package/dist/cli/commands/init-templates.js +1341 -0
- package/dist/cli/commands/init.d.ts +67 -0
- package/dist/cli/commands/init.js +301 -2
- package/dist/cli/commands/run.js +9 -1
- package/dist/cli/index.d.ts +11 -0
- package/dist/cli/index.js +42 -8
- package/dist/compliance/composer.d.ts +79 -0
- package/dist/compliance/composer.js +258 -0
- package/dist/compliance/errors.d.ts +64 -0
- package/dist/compliance/errors.js +85 -0
- package/dist/compliance/loader.d.ts +52 -0
- package/dist/compliance/loader.js +124 -0
- package/dist/compliance/types.d.ts +184 -0
- package/dist/compliance/types.js +41 -0
- package/dist/compliance-clearance.d.ts +269 -0
- package/dist/compliance-clearance.js +269 -0
- package/dist/config.js +17 -0
- package/dist/cost-tracker.d.ts +22 -0
- package/dist/cost-tracker.js +41 -0
- package/dist/database/adapters/external.js +5 -1
- package/dist/design-authority.d.ts +13 -0
- package/dist/design-authority.js +13 -0
- package/dist/embedding/adapters/openai-text-embedding-3-small.d.ts +71 -0
- package/dist/embedding/adapters/openai-text-embedding-3-small.js +190 -0
- package/dist/embedding/consumers/tessellation-drift.d.ts +74 -0
- package/dist/embedding/consumers/tessellation-drift.js +76 -0
- package/dist/embedding/cross-provider.d.ts +78 -0
- package/dist/embedding/cross-provider.js +75 -0
- package/dist/embedding/deprecation.d.ts +151 -0
- package/dist/embedding/deprecation.js +229 -0
- package/dist/embedding/errors.d.ts +90 -0
- package/dist/embedding/errors.js +150 -0
- package/dist/embedding/index.d.ts +29 -0
- package/dist/embedding/index.js +24 -0
- package/dist/embedding/pipeline-load.d.ts +146 -0
- package/dist/embedding/pipeline-load.js +178 -0
- package/dist/embedding/registry.d.ts +45 -0
- package/dist/embedding/registry.js +61 -0
- package/dist/embedding/stale-vector.d.ts +110 -0
- package/dist/embedding/stale-vector.js +92 -0
- package/dist/embedding/storage/index.d.ts +51 -0
- package/dist/embedding/storage/index.js +43 -0
- package/dist/embedding/storage/jsonl-backend.d.ts +150 -0
- package/dist/embedding/storage/jsonl-backend.js +332 -0
- package/dist/embedding/storage/types.d.ts +135 -0
- package/dist/embedding/storage/types.js +13 -0
- package/dist/embedding/types.d.ts +180 -0
- package/dist/embedding/types.js +10 -0
- package/dist/execute.d.ts +29 -2
- package/dist/execute.js +179 -35
- package/dist/index.d.ts +14 -2
- package/dist/index.js +19 -2
- package/dist/journey/inheritance-validator.d.ts +396 -0
- package/dist/journey/inheritance-validator.js +370 -0
- package/dist/journey/state-id-drift-rule.d.ts +137 -0
- package/dist/journey/state-id-drift-rule.js +245 -0
- package/dist/journey-sa2-router.d.ts +395 -0
- package/dist/journey-sa2-router.js +308 -0
- package/dist/models/classifier.d.ts +3 -1
- package/dist/models/classifier.js +61 -5
- package/dist/pillar-breakdown.d.ts +23 -1
- package/dist/pillar-breakdown.js +9 -0
- package/dist/runners/review-agent.js +6 -2
- package/dist/runners/runner-registry.d.ts +36 -0
- package/dist/runners/runner-registry.js +90 -0
- package/dist/runtime/attestations.d.ts +663 -11
- package/dist/runtime/attestations.js +971 -24
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/parallelism-flag.d.ts +21 -3
- package/dist/runtime/parallelism-flag.js +27 -6
- package/dist/runtime/port-allocator.d.ts +11 -0
- package/dist/runtime/port-allocator.js +30 -0
- package/dist/sa-scoring/layer3-llm.js +6 -1
- package/dist/sa-scoring/revision-proposal-config.d.ts +178 -0
- package/dist/sa-scoring/revision-proposal-config.js +198 -0
- package/dist/sa-scoring/revision-proposal.d.ts +285 -0
- package/dist/sa-scoring/revision-proposal.js +417 -0
- package/dist/signal-ingestion/adapters/community-thread.d.ts +43 -0
- package/dist/signal-ingestion/adapters/community-thread.js +55 -0
- package/dist/signal-ingestion/adapters/in-app-feedback.d.ts +67 -0
- package/dist/signal-ingestion/adapters/in-app-feedback.js +51 -0
- package/dist/signal-ingestion/adapters/manual.d.ts +78 -0
- package/dist/signal-ingestion/adapters/manual.js +112 -0
- package/dist/signal-ingestion/adapters/support-ticket.d.ts +47 -0
- package/dist/signal-ingestion/adapters/support-ticket.js +51 -0
- package/dist/signal-ingestion/classifier.d.ts +205 -0
- package/dist/signal-ingestion/classifier.js +494 -0
- package/dist/signal-ingestion/clustering-types.d.ts +36 -0
- package/dist/signal-ingestion/clustering-types.js +14 -0
- package/dist/signal-ingestion/clustering.d.ts +200 -0
- package/dist/signal-ingestion/clustering.js +413 -0
- package/dist/signal-ingestion/config.d.ts +351 -0
- package/dist/signal-ingestion/config.js +587 -0
- package/dist/signal-ingestion/d1.d.ts +252 -0
- package/dist/signal-ingestion/d1.js +235 -0
- package/dist/signal-ingestion/errors.d.ts +73 -0
- package/dist/signal-ingestion/errors.js +108 -0
- package/dist/signal-ingestion/governance-events.d.ts +181 -0
- package/dist/signal-ingestion/governance-events.js +189 -0
- package/dist/signal-ingestion/index.d.ts +35 -0
- package/dist/signal-ingestion/index.js +53 -0
- package/dist/signal-ingestion/manual-share-metric.d.ts +93 -0
- package/dist/signal-ingestion/manual-share-metric.js +106 -0
- package/dist/signal-ingestion/registry.d.ts +40 -0
- package/dist/signal-ingestion/registry.js +137 -0
- package/dist/signal-ingestion/residency.d.ts +227 -0
- package/dist/signal-ingestion/residency.js +238 -0
- package/dist/signal-ingestion/significance.d.ts +554 -0
- package/dist/signal-ingestion/significance.js +555 -0
- package/dist/signal-ingestion/types.d.ts +191 -0
- package/dist/signal-ingestion/types.js +8 -0
- package/dist/substrate/drift-composition.d.ts +270 -0
- package/dist/substrate/drift-composition.js +306 -0
- package/dist/substrate/drift-tui-surface.d.ts +61 -0
- package/dist/substrate/drift-tui-surface.js +102 -0
- package/dist/substrate/identity-class.d.ts +176 -0
- package/dist/substrate/identity-class.js +201 -0
- package/dist/tessellation/cross-soul-provenance-rule.d.ts +133 -0
- package/dist/tessellation/cross-soul-provenance-rule.js +171 -0
- package/dist/tessellation/inter-soul-embedding-distance-rule.d.ts +61 -0
- package/dist/tessellation/inter-soul-embedding-distance-rule.js +67 -0
- package/dist/tessellation/rule-registry.d.ts +269 -0
- package/dist/tessellation/rule-registry.js +92 -0
- package/dist/tessellation/soul-slug-ast-scan-rule.d.ts +90 -0
- package/dist/tessellation/soul-slug-ast-scan-rule.js +158 -0
- package/dist/tessellation-admission.d.ts +162 -0
- package/dist/tessellation-admission.js +146 -0
- package/dist/tessellation-drift.d.ts +246 -0
- package/dist/tessellation-drift.js +250 -0
- package/dist/validate-config.js +13 -0
- package/dist/validate-issue.js +2 -2
- package/dist/variant/cardinality-activation.d.ts +126 -0
- package/dist/variant/cardinality-activation.js +101 -0
- package/dist/variant/deprecation-lifecycle.d.ts +184 -0
- package/dist/variant/deprecation-lifecycle.js +208 -0
- package/dist/variant/drift-extension.d.ts +136 -0
- package/dist/variant/drift-extension.js +164 -0
- package/dist/variant/engineering-review.d.ts +185 -0
- package/dist/variant/engineering-review.js +142 -0
- package/dist/variant/index.d.ts +32 -0
- package/dist/variant/index.js +32 -0
- package/dist/variant/inheritance-validator.d.ts +165 -0
- package/dist/variant/inheritance-validator.js +139 -0
- package/dist/variant/internal-adopter/index.d.ts +11 -0
- package/dist/variant/internal-adopter/index.js +10 -0
- package/dist/variant/internal-adopter/products.d.ts +156 -0
- package/dist/variant/internal-adopter/products.js +366 -0
- package/dist/variant-admission.d.ts +316 -0
- package/dist/variant-admission.js +247 -0
- package/package.json +11 -8
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC-0030 Phase 4 — Tier 2 significance threshold, SA resonance filter,
|
|
3
|
+
* flooding detection (z-score on rolling per-source baseline), residency-
|
|
4
|
+
* violation gate.
|
|
5
|
+
*
|
|
6
|
+
* This module operates on Phase 3 `DemandCluster[]` output and produces:
|
|
7
|
+
*
|
|
8
|
+
* 1. **Tier 2 significance gate** (RFC-0030 §8): clusters must meet
|
|
9
|
+
* `minSignalCount` + `minUniqueSources` + `minTier1SignalCount` +
|
|
10
|
+
* `minClusterAgeDays` before they qualify for D1 scoring. Below-threshold
|
|
11
|
+
* clusters are marked `monitored` (not silently dropped — they remain
|
|
12
|
+
* visible for operator review).
|
|
13
|
+
*
|
|
14
|
+
* 2. **SA resonance filter** (RFC-0030 §9 + RFC-0029 Principle 4): clusters
|
|
15
|
+
* are bucketed by SA resonance score against the current Soul DID:
|
|
16
|
+
* - `>= fullWeight` → `full` (no D1 weight discount)
|
|
17
|
+
* - `>= discounted` → `discounted` (D1 weight × 0.7)
|
|
18
|
+
* - `>= excluded` (exclusive) → `low-sa-review` (D1 weight × 0.3 +
|
|
19
|
+
* Decision logged for Product Lead batch review per AC #3)
|
|
20
|
+
* - `<= excluded` (== 0.0) → `out-of-scope` (excluded from D1;
|
|
21
|
+
* logged as out-of-scope demand)
|
|
22
|
+
*
|
|
23
|
+
* 3. **OQ-13.5 flooding detection (AISDLC-433 v0.3 refinement)** — REPLACES
|
|
24
|
+
* the previous fixed-multiplier detector with z-score on the same
|
|
25
|
+
* per-source rolling baseline:
|
|
26
|
+
* - For every source in the detection window, compute its
|
|
27
|
+
* `windowCount` (signals in the last `windowMinutes`) and compare
|
|
28
|
+
* to the per-source baseline `{mean, stddev}` computed from the
|
|
29
|
+
* rolling `baselineDays`-day history.
|
|
30
|
+
* - Trigger condition (RFC-0030 §13.5):
|
|
31
|
+
* `windowCount > (mean + zScoreThreshold × stddev)`
|
|
32
|
+
* AND `uniqueSources_in_window < minUniqueSourcesForSuspicion`
|
|
33
|
+
* → emit `Decision: signal-flooding-detected`.
|
|
34
|
+
* - **Cold-start handling** (AC #4): when the rolling baseline has
|
|
35
|
+
* fewer than `baselineDays` of history, the detector returns the
|
|
36
|
+
* `calibrating` status and emits NO Decision — Tier 2 significance
|
|
37
|
+
* is the sole defense during the calibration window.
|
|
38
|
+
* - **Quarantine** (AC #6, #7): flooding signals are tagged
|
|
39
|
+
* `quarantined: true` with an `expiresAt` timestamp; quarantined
|
|
40
|
+
* signals do NOT feed D1 (D1 path filters them out). Default
|
|
41
|
+
* duration 24h; per-org `flooding.quarantineDurationHours`
|
|
42
|
+
* override. Auto-expiry releases signals back to D1 candidacy.
|
|
43
|
+
* - **Operator one-click unquarantine** (AC #8, #9): the surface
|
|
44
|
+
* composes with RFC-0023 Blockers pane via the `QuarantineStore`
|
|
45
|
+
* API + the `unquarantineFlooded()` helper. Unquarantine emits
|
|
46
|
+
* `Decision: signal-flooding-false-positive` referencing the
|
|
47
|
+
* original flooding Decision (feedback signal for v2
|
|
48
|
+
* reputation-weighting calibration).
|
|
49
|
+
* Pipeline NEVER halts on flooding (G0 non-blocking contract).
|
|
50
|
+
*
|
|
51
|
+
* 4. **OQ-13.3 residency-violation gate** (adapter-level): per `checkSignalResidency`,
|
|
52
|
+
* a signal whose `region` falls outside the adopter's declared regime
|
|
53
|
+
* `allowedRegions` is refused at adapter level. Pipeline never halts.
|
|
54
|
+
*
|
|
55
|
+
* @module signal-ingestion/significance
|
|
56
|
+
*/
|
|
57
|
+
import { DEFAULT_SIGNAL_INGESTION_CONFIG } from './config.js';
|
|
58
|
+
/** Per-bucket D1 weight multiplier per RFC-0030 §9. */
|
|
59
|
+
export const SA_WEIGHT_MULTIPLIERS = Object.freeze({
|
|
60
|
+
full: 1.0,
|
|
61
|
+
discounted: 0.7,
|
|
62
|
+
'low-sa-review': 0.3,
|
|
63
|
+
'out-of-scope': 0.0,
|
|
64
|
+
pending: 0.0,
|
|
65
|
+
});
|
|
66
|
+
/**
|
|
67
|
+
* Re-export of the live default for back-compat with the previous shipped
|
|
68
|
+
* surface. Phase 4 originally exposed `DEFAULT_FLOODING_DETECTION_CONFIG`
|
|
69
|
+
* as a frozen object; AISDLC-433 routes the same name to the v0.3 z-score
|
|
70
|
+
* defaults (sourced from `DEFAULT_SIGNAL_INGESTION_CONFIG.flooding.detection`)
|
|
71
|
+
* so downstream callers continue to import a working default object.
|
|
72
|
+
*/
|
|
73
|
+
export const DEFAULT_FLOODING_DETECTION_CONFIG = Object.freeze({
|
|
74
|
+
...DEFAULT_SIGNAL_INGESTION_CONFIG.flooding.detection,
|
|
75
|
+
});
|
|
76
|
+
// ── Tier 2 significance gate (§8) ───────────────────────────────────────────
|
|
77
|
+
/**
|
|
78
|
+
* Apply the RFC-0030 §8 Tier 2 significance threshold to a single cluster.
|
|
79
|
+
*
|
|
80
|
+
* The four conditions are evaluated independently so reasons can be reported
|
|
81
|
+
* back; the cluster `qualified` IFF all four pass.
|
|
82
|
+
*
|
|
83
|
+
* `asOf` defaults to `new Date()` for cluster-age computation.
|
|
84
|
+
*/
|
|
85
|
+
export function assessTier2Significance(cluster, threshold = DEFAULT_SIGNAL_INGESTION_CONFIG.tier2SignificanceThreshold, asOf = new Date()) {
|
|
86
|
+
const reasons = {
|
|
87
|
+
signalCount: cluster.signalCount < threshold.minSignalCount,
|
|
88
|
+
uniqueSources: cluster.uniqueSources < threshold.minUniqueSources,
|
|
89
|
+
tier1SignalCount: cluster.tier1SignalCount < threshold.minTier1SignalCount,
|
|
90
|
+
clusterAgeDays: clusterAgeDays(cluster, asOf) < threshold.minClusterAgeDays,
|
|
91
|
+
};
|
|
92
|
+
const qualified = !reasons.signalCount &&
|
|
93
|
+
!reasons.uniqueSources &&
|
|
94
|
+
!reasons.tier1SignalCount &&
|
|
95
|
+
!reasons.clusterAgeDays;
|
|
96
|
+
return { state: qualified ? 'qualified' : 'monitored', reasons };
|
|
97
|
+
}
|
|
98
|
+
/** Days elapsed between cluster.oldestSignalAt and `asOf`. */
|
|
99
|
+
function clusterAgeDays(cluster, asOf) {
|
|
100
|
+
const ageMs = asOf.getTime() - cluster.oldestSignalAt.getTime();
|
|
101
|
+
return ageMs / (1000 * 60 * 60 * 24);
|
|
102
|
+
}
|
|
103
|
+
// ── SA resonance filter (§9) ────────────────────────────────────────────────
|
|
104
|
+
/**
|
|
105
|
+
* Classify a cluster's SA resonance into one of five buckets per RFC-0030 §9.
|
|
106
|
+
*
|
|
107
|
+
* When `cluster.saResonance` is `undefined`, returns `pending` — the caller
|
|
108
|
+
* is expected to populate the field via the Soul DID adapter before
|
|
109
|
+
* invocation. Fail-closed: `pending` results in `eligibleForD1 = false`.
|
|
110
|
+
*/
|
|
111
|
+
export function classifySaResonance(cluster, thresholds = DEFAULT_SIGNAL_INGESTION_CONFIG.saResonanceThresholds) {
|
|
112
|
+
if (cluster.saResonance === undefined)
|
|
113
|
+
return 'pending';
|
|
114
|
+
const sa = cluster.saResonance;
|
|
115
|
+
if (sa >= thresholds.fullWeight)
|
|
116
|
+
return 'full';
|
|
117
|
+
if (sa >= thresholds.discounted)
|
|
118
|
+
return 'discounted';
|
|
119
|
+
if (sa > thresholds.excluded)
|
|
120
|
+
return 'low-sa-review';
|
|
121
|
+
return 'out-of-scope';
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Apply Phase 4 significance + SA filter to a batch of `DemandCluster`s.
|
|
125
|
+
*
|
|
126
|
+
* The resulting `assessments` carry the final `d1WeightMultiplier` that
|
|
127
|
+
* Phase 5 multiplies into the D1 formula. Below-threshold clusters retain
|
|
128
|
+
* `eligibleForD1 = false` (not silently dropped) per RFC-0030 §8.
|
|
129
|
+
*
|
|
130
|
+
* `lowSaDecisions` are emitted for every cluster whose SA bucket is
|
|
131
|
+
* `low-sa-review` — these are the AC #3 "low-SA-but-high-volume" Decisions.
|
|
132
|
+
*/
|
|
133
|
+
export function assessClusterSignificance(clusters, options = {}) {
|
|
134
|
+
const config = options.config ?? DEFAULT_SIGNAL_INGESTION_CONFIG;
|
|
135
|
+
const asOf = options.asOf ?? new Date();
|
|
136
|
+
const assessments = [];
|
|
137
|
+
const lowSaDecisions = [];
|
|
138
|
+
const outOfScopeDecisions = [];
|
|
139
|
+
for (const cluster of clusters) {
|
|
140
|
+
const { state: tier2Significance, reasons: tier2Reasons } = assessTier2Significance(cluster, config.tier2SignificanceThreshold, asOf);
|
|
141
|
+
const saResonanceBucket = classifySaResonance(cluster, config.saResonanceThresholds);
|
|
142
|
+
const significanceMultiplier = tier2Significance === 'qualified' ? 1.0 : 0.0;
|
|
143
|
+
const saMultiplier = SA_WEIGHT_MULTIPLIERS[saResonanceBucket];
|
|
144
|
+
const d1WeightMultiplier = significanceMultiplier * saMultiplier;
|
|
145
|
+
const eligibleForD1 = tier2Significance === 'qualified' &&
|
|
146
|
+
saResonanceBucket !== 'out-of-scope' &&
|
|
147
|
+
saResonanceBucket !== 'pending';
|
|
148
|
+
assessments.push({
|
|
149
|
+
cluster,
|
|
150
|
+
tier2Significance,
|
|
151
|
+
tier2Reasons,
|
|
152
|
+
saResonanceBucket,
|
|
153
|
+
eligibleForD1,
|
|
154
|
+
d1WeightMultiplier,
|
|
155
|
+
});
|
|
156
|
+
// AC #3 — low-SA demand surfaces via Decision for Product Lead review.
|
|
157
|
+
// Emitted even when the cluster is `monitored` (below significance) — the
|
|
158
|
+
// operator should see that low-SA demand IS accumulating, regardless of
|
|
159
|
+
// whether the Tier 2 threshold has been met.
|
|
160
|
+
if (saResonanceBucket === 'low-sa-review' && cluster.saResonance !== undefined) {
|
|
161
|
+
lowSaDecisions.push({
|
|
162
|
+
type: 'Decision',
|
|
163
|
+
decision: 'signal-low-sa-for-review',
|
|
164
|
+
clusterId: cluster.clusterId,
|
|
165
|
+
saResonance: cluster.saResonance,
|
|
166
|
+
signalCount: cluster.signalCount,
|
|
167
|
+
message: `Cluster ${cluster.clusterId} has SA resonance ${cluster.saResonance.toFixed(3)} ` +
|
|
168
|
+
`(below 'discounted' threshold ${config.saResonanceThresholds.discounted.toFixed(2)}); ` +
|
|
169
|
+
`flagging for Product Lead batch review.`,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
if (saResonanceBucket === 'out-of-scope' && cluster.saResonance !== undefined) {
|
|
173
|
+
outOfScopeDecisions.push({
|
|
174
|
+
type: 'Decision',
|
|
175
|
+
decision: 'signal-out-of-scope',
|
|
176
|
+
clusterId: cluster.clusterId,
|
|
177
|
+
saResonance: cluster.saResonance,
|
|
178
|
+
signalCount: cluster.signalCount,
|
|
179
|
+
message: `Cluster ${cluster.clusterId} has SA resonance ${cluster.saResonance.toFixed(3)} ` +
|
|
180
|
+
`(at or below 'excluded' threshold ${config.saResonanceThresholds.excluded.toFixed(2)}); ` +
|
|
181
|
+
`logging as out-of-scope demand for separate triage.`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { assessments, lowSaDecisions, outOfScopeDecisions };
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Z-score detector — RFC-0030 §13.5 v0.3 trigger condition is
|
|
189
|
+
* `windowCount > (mean + zScoreThreshold × stddev) AND uniqueSources_in_window
|
|
190
|
+
* < minUniqueSourcesForSuspicion`.
|
|
191
|
+
*
|
|
192
|
+
* Returns a `FloodingDetectionResult` whose `status` discriminates the four
|
|
193
|
+
* outcomes (flooded / clean / calibrating / empty-window). The caller routes
|
|
194
|
+
* `result.decision` to the catalog when `status === 'flooded'`.
|
|
195
|
+
*
|
|
196
|
+
* Cold-start (AC #4): when the maximum per-source `baselineDays` observed
|
|
197
|
+
* across all in-window sources is < `config.baselineDays`, the detector
|
|
198
|
+
* returns `status: 'calibrating'` with no Decision — Tier 2 significance is
|
|
199
|
+
* the sole defense until the rolling baseline has built up.
|
|
200
|
+
*
|
|
201
|
+
* Quarantine (AC #6, #7): when `quarantineConfig.enabled`, every in-window
|
|
202
|
+
* signal from a flagged source is tagged in the supplied `quarantineStore`
|
|
203
|
+
* with `expiresAt = asOf + quarantineDurationHours`. Auto-expiry happens
|
|
204
|
+
* lazily on read (see `isSignalQuarantined()`).
|
|
205
|
+
*/
|
|
206
|
+
export function detectFlooding(signals, options = {}) {
|
|
207
|
+
const config = options.config ?? DEFAULT_FLOODING_DETECTION_CONFIG;
|
|
208
|
+
const quarantineConfig = options.quarantineConfig ?? DEFAULT_SIGNAL_INGESTION_CONFIG.flooding.quarantine;
|
|
209
|
+
const asOf = options.asOf ?? new Date();
|
|
210
|
+
const perSourceBaselines = options.perSourceBaselines ?? {};
|
|
211
|
+
const generateDecisionId = options.generateDecisionId ?? defaultDecisionIdFactory(asOf);
|
|
212
|
+
const windowStartMs = asOf.getTime() - config.windowMinutes * 60 * 1000;
|
|
213
|
+
const windowEndMs = asOf.getTime();
|
|
214
|
+
const windowSignals = signals.filter((s) => {
|
|
215
|
+
const ts = s.sourceTimestamp.getTime();
|
|
216
|
+
return ts >= windowStartMs && ts <= windowEndMs;
|
|
217
|
+
});
|
|
218
|
+
if (windowSignals.length === 0) {
|
|
219
|
+
return { status: 'empty-window', signalCount: 0, uniqueSources: 0, baselineDaysObserved: 0 };
|
|
220
|
+
}
|
|
221
|
+
// Group window signals by source.
|
|
222
|
+
const perSourceWindowCounts = new Map();
|
|
223
|
+
const perSourceWindowSignals = new Map();
|
|
224
|
+
for (const s of windowSignals) {
|
|
225
|
+
const src = resolveSourceName(s);
|
|
226
|
+
perSourceWindowCounts.set(src, (perSourceWindowCounts.get(src) ?? 0) + 1);
|
|
227
|
+
const bucket = perSourceWindowSignals.get(src) ?? [];
|
|
228
|
+
bucket.push(s);
|
|
229
|
+
perSourceWindowSignals.set(src, bucket);
|
|
230
|
+
}
|
|
231
|
+
const signalCount = windowSignals.length;
|
|
232
|
+
const uniqueSources = perSourceWindowCounts.size;
|
|
233
|
+
// Cold-start: take max baselineDays across all in-window sources. When ANY
|
|
234
|
+
// source has the full window observed, the detector is calibrated for that
|
|
235
|
+
// source; when NO source has the full window, we're calibrating overall.
|
|
236
|
+
let maxBaselineDays = 0;
|
|
237
|
+
for (const src of perSourceWindowCounts.keys()) {
|
|
238
|
+
const samples = perSourceBaselines[src] ?? [];
|
|
239
|
+
if (samples.length > maxBaselineDays)
|
|
240
|
+
maxBaselineDays = samples.length;
|
|
241
|
+
}
|
|
242
|
+
if (maxBaselineDays < config.baselineDays) {
|
|
243
|
+
return {
|
|
244
|
+
status: 'calibrating',
|
|
245
|
+
signalCount,
|
|
246
|
+
uniqueSources,
|
|
247
|
+
baselineDaysObserved: maxBaselineDays,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
// Trigger condition guard #2: uniqueSources_in_window < minUniqueSourcesForSuspicion.
|
|
251
|
+
// When uniqueSources >= threshold, a "many sources contributing" pattern is
|
|
252
|
+
// healthy organic traffic — z-score alone is not enough to call it flooding.
|
|
253
|
+
// RFC-0030 §13.5: "trigger when volume > 3σ AND uniqueSources < 3".
|
|
254
|
+
if (uniqueSources >= config.minUniqueSourcesForSuspicion) {
|
|
255
|
+
return {
|
|
256
|
+
status: 'clean',
|
|
257
|
+
signalCount,
|
|
258
|
+
uniqueSources,
|
|
259
|
+
baselineDaysObserved: maxBaselineDays,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
// Per-source z-score check.
|
|
263
|
+
const flaggedSources = [];
|
|
264
|
+
for (const [src, windowCount] of perSourceWindowCounts) {
|
|
265
|
+
const samples = perSourceBaselines[src] ?? [];
|
|
266
|
+
if (samples.length < config.baselineDays)
|
|
267
|
+
continue; // skip cold per-source
|
|
268
|
+
const stats = computeBaselineStat(samples);
|
|
269
|
+
const zScore = computeZScore(windowCount, stats);
|
|
270
|
+
if (zScore > config.zScoreThreshold) {
|
|
271
|
+
flaggedSources.push({
|
|
272
|
+
sourceId: src,
|
|
273
|
+
zScore,
|
|
274
|
+
windowCount,
|
|
275
|
+
baselineMean: stats.mean,
|
|
276
|
+
baselineStddev: stats.stddev,
|
|
277
|
+
baselineDays: stats.sampleCount,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (flaggedSources.length === 0) {
|
|
282
|
+
return {
|
|
283
|
+
status: 'clean',
|
|
284
|
+
signalCount,
|
|
285
|
+
uniqueSources,
|
|
286
|
+
baselineDaysObserved: maxBaselineDays,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
// Build Decision + apply quarantine.
|
|
290
|
+
const decisionId = generateDecisionId();
|
|
291
|
+
const detectedAt = asOf.toISOString();
|
|
292
|
+
const quarantinedSourceIds = [];
|
|
293
|
+
const quarantineDurationHours = quarantineConfig.enabled ? quarantineConfig.durationHours : 0;
|
|
294
|
+
if (quarantineConfig.enabled) {
|
|
295
|
+
const expiresAt = new Date(asOf.getTime() + quarantineDurationHours * 60 * 60 * 1000);
|
|
296
|
+
for (const flag of flaggedSources) {
|
|
297
|
+
quarantinedSourceIds.push(flag.sourceId);
|
|
298
|
+
const sourceSignals = perSourceWindowSignals.get(flag.sourceId) ?? [];
|
|
299
|
+
if (options.quarantineStore !== undefined) {
|
|
300
|
+
for (const sig of sourceSignals) {
|
|
301
|
+
options.quarantineStore.quarantine({
|
|
302
|
+
sourceId: sig.sourceId,
|
|
303
|
+
adapterSource: flag.sourceId,
|
|
304
|
+
decisionId,
|
|
305
|
+
quarantinedAt: asOf,
|
|
306
|
+
expiresAt,
|
|
307
|
+
reason: `z-score ${flag.zScore.toFixed(2)}σ (baseline mean=${flag.baselineMean.toFixed(2)}, stddev=${flag.baselineStddev.toFixed(2)})`,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const decision = {
|
|
314
|
+
type: 'Decision',
|
|
315
|
+
decision: 'signal-flooding-detected',
|
|
316
|
+
decisionId,
|
|
317
|
+
detectedAt,
|
|
318
|
+
signalCount,
|
|
319
|
+
uniqueSources,
|
|
320
|
+
flaggedSources,
|
|
321
|
+
quarantinedSourceIds,
|
|
322
|
+
quarantineDurationHours,
|
|
323
|
+
message: floodingMessage(flaggedSources, signalCount, uniqueSources, quarantineDurationHours),
|
|
324
|
+
};
|
|
325
|
+
return {
|
|
326
|
+
status: 'flooded',
|
|
327
|
+
decision,
|
|
328
|
+
signalCount,
|
|
329
|
+
uniqueSources,
|
|
330
|
+
baselineDaysObserved: maxBaselineDays,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Population-standard-deviation baseline stat. Exposed for unit testing.
|
|
335
|
+
* Returns `{mean: 0, stddev: 0, sampleCount: 0}` on empty input — caller's
|
|
336
|
+
* cold-start gate should prevent that from reaching `computeZScore()`.
|
|
337
|
+
*/
|
|
338
|
+
export function computeBaselineStat(samples) {
|
|
339
|
+
if (samples.length === 0)
|
|
340
|
+
return { mean: 0, stddev: 0, sampleCount: 0 };
|
|
341
|
+
const sum = samples.reduce((acc, n) => acc + n, 0);
|
|
342
|
+
const mean = sum / samples.length;
|
|
343
|
+
const sqSum = samples.reduce((acc, n) => acc + (n - mean) ** 2, 0);
|
|
344
|
+
const stddev = Math.sqrt(sqSum / samples.length);
|
|
345
|
+
return { mean, stddev, sampleCount: samples.length };
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Z-score of a single observation against `BaselineStat`. When `stddev === 0`
|
|
349
|
+
* (degenerate baseline — every sample identical), returns `+Infinity` if the
|
|
350
|
+
* observation exceeds the mean, `0` if it equals the mean — matches the
|
|
351
|
+
* statistical convention that any deviation from a zero-variance baseline is
|
|
352
|
+
* infinitely surprising. Exposed for unit testing.
|
|
353
|
+
*/
|
|
354
|
+
export function computeZScore(observation, stats) {
|
|
355
|
+
if (stats.stddev === 0) {
|
|
356
|
+
if (observation > stats.mean)
|
|
357
|
+
return Number.POSITIVE_INFINITY;
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
return (observation - stats.mean) / stats.stddev;
|
|
361
|
+
}
|
|
362
|
+
function floodingMessage(flaggedSources, signalCount, uniqueSources, quarantineDurationHours) {
|
|
363
|
+
const flaggedNames = flaggedSources.map((f) => `${f.sourceId} (z=${f.zScore.toFixed(2)}σ)`);
|
|
364
|
+
const quarantinePart = quarantineDurationHours > 0
|
|
365
|
+
? ` Quarantine applied for ${quarantineDurationHours}h.`
|
|
366
|
+
: ' Quarantine disabled — signals stay live.';
|
|
367
|
+
return (`Flooding detected: ${signalCount} signals over ${uniqueSources} sources in the detection window; ` +
|
|
368
|
+
`flagged sources [${flaggedNames.join(', ')}].${quarantinePart}`);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Resolve a source identifier for a signal — prefers `metadata.adapterName`,
|
|
372
|
+
* falls back to a `sourceId` prefix (the segment before the first `-`).
|
|
373
|
+
*/
|
|
374
|
+
function resolveSourceName(signal) {
|
|
375
|
+
const fromMeta = signal.metadata?.['adapterName'];
|
|
376
|
+
if (typeof fromMeta === 'string' && fromMeta.length > 0)
|
|
377
|
+
return fromMeta;
|
|
378
|
+
const dashIdx = signal.sourceId.indexOf('-');
|
|
379
|
+
if (dashIdx > 0)
|
|
380
|
+
return signal.sourceId.slice(0, dashIdx);
|
|
381
|
+
return signal.sourceId;
|
|
382
|
+
}
|
|
383
|
+
function defaultDecisionIdFactory(asOf) {
|
|
384
|
+
let counter = 0;
|
|
385
|
+
return () => {
|
|
386
|
+
counter += 1;
|
|
387
|
+
const suffix = counter > 1 ? `-${counter}` : '';
|
|
388
|
+
return `flooding-${asOf.toISOString().replace(/[:.]/g, '')}${suffix}`;
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Default in-memory store. Tests use this directly; production callers should
|
|
393
|
+
* implement a persistent variant satisfying the same interface.
|
|
394
|
+
*/
|
|
395
|
+
export class InMemoryQuarantineStore {
|
|
396
|
+
entries = [];
|
|
397
|
+
released = new Set();
|
|
398
|
+
quarantine(entry) {
|
|
399
|
+
this.entries.push(entry);
|
|
400
|
+
}
|
|
401
|
+
isQuarantined(sourceId, asOf = new Date()) {
|
|
402
|
+
const ms = asOf.getTime();
|
|
403
|
+
for (const e of this.entries) {
|
|
404
|
+
if (e.sourceId !== sourceId)
|
|
405
|
+
continue;
|
|
406
|
+
if (this.released.has(e.decisionId))
|
|
407
|
+
continue;
|
|
408
|
+
if (e.expiresAt.getTime() <= ms)
|
|
409
|
+
continue;
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
getActiveEntries(asOf = new Date()) {
|
|
415
|
+
const ms = asOf.getTime();
|
|
416
|
+
return this.entries.filter((e) => !this.released.has(e.decisionId) && e.expiresAt.getTime() > ms);
|
|
417
|
+
}
|
|
418
|
+
getEntryByDecisionId(decisionId) {
|
|
419
|
+
return this.entries.filter((e) => e.decisionId === decisionId);
|
|
420
|
+
}
|
|
421
|
+
release(decisionId, _releasedAt = new Date()) {
|
|
422
|
+
const matched = this.getEntryByDecisionId(decisionId);
|
|
423
|
+
if (matched.length === 0)
|
|
424
|
+
return [];
|
|
425
|
+
this.released.add(decisionId);
|
|
426
|
+
return matched;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Convenience: check whether a single `RawSignal` is currently quarantined by
|
|
431
|
+
* looking up its `sourceId` in the store. The D1 path calls this when
|
|
432
|
+
* computing `eligibleForD1` — quarantined signals are excluded from the
|
|
433
|
+
* D1(cluster) formula per AC #6.
|
|
434
|
+
*
|
|
435
|
+
* Returns `false` when no store is supplied (back-compat — pre-AISDLC-433
|
|
436
|
+
* D1 paths didn't have a quarantine layer to consult).
|
|
437
|
+
*/
|
|
438
|
+
export function isSignalQuarantined(signal, store, asOf = new Date()) {
|
|
439
|
+
if (store === undefined)
|
|
440
|
+
return false;
|
|
441
|
+
return store.isQuarantined(signal.sourceId, asOf);
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Operator one-click unquarantine — releases every signal entry tagged with
|
|
445
|
+
* `originalDecisionId` and emits the `signal-flooding-false-positive`
|
|
446
|
+
* Decision per RFC-0030 §13.5 + AC #8 + AC #9.
|
|
447
|
+
*
|
|
448
|
+
* Returns `null` when no active entries match the Decision ID (idempotent —
|
|
449
|
+
* the operator's second click on the same row is a no-op + null result).
|
|
450
|
+
*/
|
|
451
|
+
export function unquarantineFlooded(options) {
|
|
452
|
+
const asOf = options.asOf ?? new Date();
|
|
453
|
+
const generateDecisionId = options.generateDecisionId ?? falsePositiveDecisionIdFactory(asOf, options.originalDecisionId);
|
|
454
|
+
const released = options.store.release(options.originalDecisionId, asOf);
|
|
455
|
+
if (released.length === 0)
|
|
456
|
+
return null;
|
|
457
|
+
const releasedSourceIds = Array.from(new Set(released.map((e) => e.sourceId)));
|
|
458
|
+
const decisionId = generateDecisionId();
|
|
459
|
+
return {
|
|
460
|
+
type: 'Decision',
|
|
461
|
+
decision: 'signal-flooding-false-positive',
|
|
462
|
+
decisionId,
|
|
463
|
+
originalDecisionId: options.originalDecisionId,
|
|
464
|
+
releasedAt: asOf.toISOString(),
|
|
465
|
+
releasedSourceIds,
|
|
466
|
+
operatorNote: options.operatorNote,
|
|
467
|
+
message: `Operator marked flooding Decision ${options.originalDecisionId} as false-positive; ` +
|
|
468
|
+
`released ${releasedSourceIds.length} source(s) back to D1 candidacy. ` +
|
|
469
|
+
`v2 reputation-weighting layer will use this Decision as calibration signal.`,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
function falsePositiveDecisionIdFactory(asOf, originalDecisionId) {
|
|
473
|
+
return () => `flooding-fp-${asOf.toISOString().replace(/[:.]/g, '')}-${originalDecisionId.slice(-8)}`;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Check a single signal against the adopter's declared residency regimes.
|
|
477
|
+
*
|
|
478
|
+
* **Behaviour**:
|
|
479
|
+
* - When `declaration.regimes` is empty → signal is permitted (no regime
|
|
480
|
+
* constraints declared). Adopters not declaring a regime are not subject
|
|
481
|
+
* to residency gating.
|
|
482
|
+
* - When `signal.region` is `undefined` AND at least one regime is active →
|
|
483
|
+
* signal is permitted (the adapter didn't surface region metadata, which
|
|
484
|
+
* is treated as "not subject to gating" rather than "fails the gate" to
|
|
485
|
+
* avoid false-positives on adapters that don't yet plumb region — a
|
|
486
|
+
* visible-gap metric for the operator's regime config rollout).
|
|
487
|
+
* - When `signal.region` is present AND at least one active regime's
|
|
488
|
+
* `allowedRegions` does NOT include it → signal is REFUSED and the
|
|
489
|
+
* `Decision` records every regime that rejected the signal.
|
|
490
|
+
*
|
|
491
|
+
* The adapter SHOULD NOT call `fetchSignals` for refused signals — the
|
|
492
|
+
* return-value pattern lets the adapter short-circuit per-signal.
|
|
493
|
+
*/
|
|
494
|
+
export function checkSignalResidency(signal, declaration, adapterName) {
|
|
495
|
+
if (declaration.regimes.length === 0)
|
|
496
|
+
return { permitted: true };
|
|
497
|
+
if (signal.region === undefined)
|
|
498
|
+
return { permitted: true };
|
|
499
|
+
const region = signal.region.toLowerCase();
|
|
500
|
+
const violatedRegimes = [];
|
|
501
|
+
const allowedAcrossAllRegimes = new Set();
|
|
502
|
+
for (const regime of declaration.regimes) {
|
|
503
|
+
const allowed = declaration.allowedRegionsByRegime[regime] ?? [];
|
|
504
|
+
if (allowed.length === 0) {
|
|
505
|
+
// Active regime with no allowed regions = all signals violate.
|
|
506
|
+
violatedRegimes.push(regime);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const allowedLower = allowed.map((r) => r.toLowerCase());
|
|
510
|
+
for (const r of allowedLower)
|
|
511
|
+
allowedAcrossAllRegimes.add(r);
|
|
512
|
+
if (!allowedLower.includes(region)) {
|
|
513
|
+
violatedRegimes.push(regime);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (violatedRegimes.length === 0)
|
|
517
|
+
return { permitted: true };
|
|
518
|
+
return {
|
|
519
|
+
permitted: false,
|
|
520
|
+
decision: {
|
|
521
|
+
type: 'Decision',
|
|
522
|
+
decision: 'signal-residency-violation',
|
|
523
|
+
adapter: adapterName,
|
|
524
|
+
sourceId: signal.sourceId,
|
|
525
|
+
signalRegion: signal.region,
|
|
526
|
+
violatedRegimes,
|
|
527
|
+
allowedRegions: Array.from(allowedAcrossAllRegimes).sort(),
|
|
528
|
+
message: `Signal ${signal.sourceId} from adapter '${adapterName}' has region '${signal.region}' ` +
|
|
529
|
+
`which violates the residency constraint(s) of active regime(s) [${violatedRegimes.join(', ')}]; ` +
|
|
530
|
+
`signal refused. Emit clarification task to update compliance.yaml regimeOverrides if ` +
|
|
531
|
+
`the regime declaration is incorrect, or drop the source if non-compliant.`,
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Convenience wrapper: filter a batch of signals against the residency gate,
|
|
537
|
+
* returning the (possibly empty) list of permitted signals + the list of
|
|
538
|
+
* Decision records for refused signals. Adapter implementations can call this
|
|
539
|
+
* in their `fetchSignals()` body to enforce residency before returning.
|
|
540
|
+
*/
|
|
541
|
+
export function filterSignalsByResidency(signals, declaration, adapterName) {
|
|
542
|
+
const permitted = [];
|
|
543
|
+
const decisions = [];
|
|
544
|
+
for (const s of signals) {
|
|
545
|
+
const result = checkSignalResidency(s, declaration, adapterName);
|
|
546
|
+
if (result.permitted) {
|
|
547
|
+
permitted.push(s);
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
decisions.push(result.decision);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return { permitted, decisions };
|
|
554
|
+
}
|
|
555
|
+
//# sourceMappingURL=significance.js.map
|