@var-ia/eval 0.1.1

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/src/index.ts ADDED
@@ -0,0 +1,264 @@
1
+ import type { EvidenceEvent } from "@var-ia/evidence-graph";
2
+
3
+ // ── L3 Ground Truth Types ──────────────────────────────────────────
4
+
5
+ export interface OutcomeLabel {
6
+ id: string;
7
+ source: "talk_page_consensus" | "rfc_closure" | "arbcom_decision" | "page_protection";
8
+ pageTitle: string;
9
+ description: string;
10
+ observedAt: string;
11
+ resolution: "keep" | "merge" | "delete" | "no_consensus" | "redirect" | "other";
12
+ referenceUrl: string;
13
+ expectedEventTypes: string[];
14
+ expectedSection?: string;
15
+ }
16
+
17
+ export interface L3ValidationResult {
18
+ outcomeId: string;
19
+ passed: boolean;
20
+ description: string;
21
+ signalDetected: boolean;
22
+ matchedEvents: EvidenceEvent[];
23
+ expectedEventTypes: string[];
24
+ precision: number;
25
+ recall: number;
26
+ }
27
+
28
+ export interface L3ValidationSummary {
29
+ totalOutcomes: number;
30
+ passed: number;
31
+ failed: number;
32
+ overallPrecision: number;
33
+ overallRecall: number;
34
+ perOutcome: L3ValidationResult[];
35
+ }
36
+
37
+ export function validateAgainstGroundTruth(outcomes: OutcomeLabel[], events: EvidenceEvent[]): L3ValidationSummary {
38
+ const results: L3ValidationResult[] = outcomes.map((outcome) => {
39
+ const expected = outcome.expectedEventTypes;
40
+ const matched = events.filter(
41
+ (e) => expected.includes(e.eventType) && (!outcome.expectedSection || e.section === outcome.expectedSection),
42
+ );
43
+
44
+ const signalDetected = matched.length > 0;
45
+ const precision =
46
+ matched.length > 0
47
+ ? expected.filter((et) => matched.some((m) => m.eventType === et)).length / expected.length
48
+ : 0;
49
+ const recall = matched.length > 0 ? 1.0 : 0.0;
50
+
51
+ return {
52
+ outcomeId: outcome.id,
53
+ passed: signalDetected,
54
+ description: outcome.description,
55
+ signalDetected,
56
+ matchedEvents: matched,
57
+ expectedEventTypes: expected,
58
+ precision,
59
+ recall,
60
+ };
61
+ });
62
+
63
+ const passed = results.filter((r) => r.passed);
64
+ const avgPrecision = results.length > 0 ? results.reduce((s, r) => s + r.precision, 0) / results.length : 0;
65
+ const avgRecall = results.length > 0 ? results.reduce((s, r) => s + r.recall, 0) / results.length : 0;
66
+
67
+ return {
68
+ totalOutcomes: outcomes.length,
69
+ passed: passed.length,
70
+ failed: outcomes.length - passed.length,
71
+ overallPrecision: avgPrecision,
72
+ overallRecall: avgRecall,
73
+ perOutcome: results,
74
+ };
75
+ }
76
+
77
+ export interface EvalTestCase {
78
+ id: string;
79
+ description: string;
80
+ pageTitle: string;
81
+ pageId: number;
82
+ revisionRange: { from: number; to: number };
83
+ expectedEvents: ExpectedEvent[];
84
+ tolerance?: EvalTolerance;
85
+ }
86
+
87
+ export interface ExpectedEvent {
88
+ eventType: string;
89
+ section: string;
90
+ minConfidence?: number;
91
+ }
92
+
93
+ export interface EvalTolerance {
94
+ minEventCount?: number;
95
+ maxEventCount?: number;
96
+ minPrecision?: number;
97
+ }
98
+
99
+ export interface EvalResult {
100
+ testId: string;
101
+ passed: boolean;
102
+ precision: number;
103
+ eventCount: { expected: number; actual: number };
104
+ matches: EventMatch[];
105
+ misses: MissingEvent[];
106
+ falsePositives: UnexpectedEvent[];
107
+ }
108
+
109
+ export interface EventMatch {
110
+ expected: ExpectedEvent;
111
+ actual: EvidenceEvent;
112
+ }
113
+
114
+ export interface MissingEvent {
115
+ expected: ExpectedEvent;
116
+ }
117
+
118
+ export interface UnexpectedEvent {
119
+ event: EvidenceEvent;
120
+ }
121
+
122
+ export interface EvalHarness {
123
+ evaluate(test: EvalTestCase, events: EvidenceEvent[]): EvalResult;
124
+ benchmarkPages(): EvalTestCase[];
125
+ computeScores(results: EvalResult[]): EvalScoreSummary;
126
+ }
127
+
128
+ export interface EvalScoreSummary {
129
+ overallPrecision: number;
130
+ testsPassed: number;
131
+ testsFailed: number;
132
+ totalTests: number;
133
+ perTest: Array<{ id: string; precision: number; passed: boolean }>;
134
+ }
135
+
136
+ export function createEvalHarness(): EvalHarness {
137
+ return {
138
+ evaluate(test, events) {
139
+ const matches: EventMatch[] = [];
140
+ const misses: MissingEvent[] = [];
141
+ const falsePositives: UnexpectedEvent[] = [];
142
+
143
+ for (const expected of test.expectedEvents) {
144
+ const found = events.find((e) => e.eventType === expected.eventType && e.section === expected.section);
145
+ if (found) {
146
+ matches.push({ expected, actual: found });
147
+ } else {
148
+ misses.push({ expected });
149
+ }
150
+ }
151
+
152
+ for (const event of events) {
153
+ if (!test.expectedEvents.some((e) => e.eventType === event.eventType)) {
154
+ falsePositives.push({ event });
155
+ }
156
+ }
157
+
158
+ const matchedCount = matches.length;
159
+ const totalExpected = test.expectedEvents.length;
160
+ const precision = totalExpected > 0 ? matchedCount / totalExpected : events.length === 0 ? 1.0 : 0.0;
161
+
162
+ const tolerance = test.tolerance ?? {};
163
+ const minEventCount = tolerance.minEventCount ?? 0;
164
+ const maxEventCount = tolerance.maxEventCount ?? Infinity;
165
+ const minPrecision = tolerance.minPrecision ?? 0.5;
166
+
167
+ const passed = precision >= minPrecision && events.length >= minEventCount && events.length <= maxEventCount;
168
+
169
+ return {
170
+ testId: test.id,
171
+ passed,
172
+ precision,
173
+ eventCount: { expected: totalExpected, actual: events.length },
174
+ matches,
175
+ misses,
176
+ falsePositives,
177
+ };
178
+ },
179
+
180
+ benchmarkPages(): EvalTestCase[] {
181
+ return [
182
+ {
183
+ id: "page-has-revisions",
184
+ description: "Any active Wikipedia page returns at least 2 revisions and generates section events",
185
+ pageTitle: "Earth",
186
+ pageId: 9228,
187
+ revisionRange: { from: 0, to: 0 },
188
+ expectedEvents: [{ eventType: "section_reorganized", section: "(lead)" }],
189
+ tolerance: { minEventCount: 1, minPrecision: 0.0 },
190
+ },
191
+ {
192
+ id: "contentious-page-has-reverts",
193
+ description: "Pages with edit wars should have revert events",
194
+ pageTitle: "Donald_Trump",
195
+ pageId: 4848272,
196
+ revisionRange: { from: 0, to: 0 },
197
+ expectedEvents: [{ eventType: "revert_detected", section: "" }],
198
+ tolerance: { minEventCount: 1, minPrecision: 0.0 },
199
+ },
200
+ {
201
+ id: "controversy-page-has-templates",
202
+ description: "Controversial topics have policy maintenance templates",
203
+ pageTitle: "COVID-19_pandemic",
204
+ pageId: 58899562,
205
+ revisionRange: { from: 0, to: 0 },
206
+ expectedEvents: [{ eventType: "template_added", section: "body" }],
207
+ tolerance: { minEventCount: 5, minPrecision: 0.0 },
208
+ },
209
+ {
210
+ id: "scientific-article-has-citations",
211
+ description: "Scientific articles always have citation changes",
212
+ pageTitle: "CRISPR",
213
+ pageId: 5000000,
214
+ revisionRange: { from: 0, to: 0 },
215
+ expectedEvents: [
216
+ { eventType: "citation_added", section: "body" },
217
+ { eventType: "citation_removed", section: "body" },
218
+ ],
219
+ tolerance: { minEventCount: 3, minPrecision: 0.1 },
220
+ },
221
+ {
222
+ id: "featured-article-has-template-cleanup",
223
+ description: "Featured articles show cleanup/maintenance template activity",
224
+ pageTitle: "Shakespeare",
225
+ pageId: 26825,
226
+ revisionRange: { from: 0, to: 0 },
227
+ expectedEvents: [
228
+ { eventType: "template_added", section: "body" },
229
+ { eventType: "section_reorganized", section: "(lead)" },
230
+ ],
231
+ tolerance: { minEventCount: 5, minPrecision: 0.1 },
232
+ },
233
+ {
234
+ id: "events-has-citation-additions",
235
+ description: "Pages with many citations will have observable citation diffs",
236
+ pageTitle: "Albert_Einstein",
237
+ pageId: 736,
238
+ revisionRange: { from: 0, to: 0 },
239
+ expectedEvents: [{ eventType: "citation_added", section: "body" }],
240
+ tolerance: { minEventCount: 2, minPrecision: 0.0 },
241
+ },
242
+ ];
243
+ },
244
+
245
+ computeScores(results) {
246
+ const passed = results.filter((r) => r.passed);
247
+ const totalPrecision = results.length > 0 ? results.reduce((sum, r) => sum + r.precision, 0) / results.length : 0;
248
+
249
+ return {
250
+ overallPrecision: totalPrecision,
251
+ testsPassed: passed.length,
252
+ testsFailed: results.length - passed.length,
253
+ totalTests: results.length,
254
+ perTest: results.map((r) => ({
255
+ id: r.testId,
256
+ precision: r.precision,
257
+ passed: r.passed,
258
+ })),
259
+ };
260
+ },
261
+ };
262
+ }
263
+
264
+ export { GROUND_TRUTH_LABELS, getGroundTruthById, getGroundTruthForPage } from "./ground-truth.js";
@@ -0,0 +1,454 @@
1
+ import type { EvidenceEvent } from "@var-ia/evidence-graph";
2
+ import type { ModelConfig } from "@var-ia/interpreter";
3
+ import { createAdapter } from "@var-ia/interpreter";
4
+
5
+ export interface L2TestCase {
6
+ id: string;
7
+ description: string;
8
+ events: EvidenceEvent[];
9
+ expected: ExpectedInterpretation[];
10
+ }
11
+
12
+ export interface ExpectedInterpretation {
13
+ eventIndex: number;
14
+ semanticChange?: string;
15
+ confidence?: number;
16
+ policyDimension?: string;
17
+ discussionType?: string;
18
+ }
19
+
20
+ export interface L2MetricScore {
21
+ metric: string;
22
+ correct: number;
23
+ total: number;
24
+ accuracy: number;
25
+ }
26
+
27
+ export interface L2ProviderResult {
28
+ provider: string;
29
+ model: string;
30
+ metrics: L2MetricScore[];
31
+ avgConfidence: number;
32
+ overallAccuracy: number;
33
+ totalEvents: number;
34
+ }
35
+
36
+ export interface L2BenchmarkResult {
37
+ generatedAt: string;
38
+ testCases: number;
39
+ totalEvents: number;
40
+ providers: L2ProviderResult[];
41
+ }
42
+
43
+ export async function runL2Benchmark(providers: ModelConfig[]): Promise<L2BenchmarkResult> {
44
+ const testCases = buildL2Dataset();
45
+ const totalEvents = testCases.reduce((s, tc) => s + tc.events.length, 0);
46
+ const providerResults: L2ProviderResult[] = [];
47
+
48
+ for (const config of providers) {
49
+ try {
50
+ const adapter = createAdapter(config);
51
+ const results = await runProviderBenchmark(config, adapter, testCases);
52
+ providerResults.push(results);
53
+ } catch {
54
+ providerResults.push({
55
+ provider: config.provider,
56
+ model: config.model ?? "unknown",
57
+ metrics: [],
58
+ avgConfidence: 0,
59
+ overallAccuracy: 0,
60
+ totalEvents: 0,
61
+ });
62
+ }
63
+ }
64
+
65
+ return {
66
+ generatedAt: new Date().toISOString(),
67
+ testCases: testCases.length,
68
+ totalEvents,
69
+ providers: providerResults,
70
+ };
71
+ }
72
+
73
+ async function runProviderBenchmark(
74
+ config: ModelConfig,
75
+ adapter: ReturnType<typeof createAdapter>,
76
+ testCases: L2TestCase[],
77
+ ): Promise<L2ProviderResult> {
78
+ let eventsCorrect = 0;
79
+ let totalEvents = 0;
80
+ let totalConfidence = 0;
81
+ let confidenceCount = 0;
82
+
83
+ const metricBuckets: Record<string, { correct: number; total: number }> = {
84
+ semanticChange: { correct: 0, total: 0 },
85
+ policyDimension: { correct: 0, total: 0 },
86
+ discussionType: { correct: 0, total: 0 },
87
+ };
88
+
89
+ for (const tc of testCases) {
90
+ const interpreted = await adapter.interpret(tc.events);
91
+
92
+ for (const exp of tc.expected) {
93
+ const actual = interpreted[exp.eventIndex];
94
+ if (!actual?.modelInterpretation) continue;
95
+
96
+ totalEvents++;
97
+ let eventCorrect = true;
98
+
99
+ if (exp.semanticChange) {
100
+ metricBuckets.semanticChange.total++;
101
+ if (actual.modelInterpretation.semanticChange === exp.semanticChange) {
102
+ metricBuckets.semanticChange.correct++;
103
+ } else {
104
+ eventCorrect = false;
105
+ }
106
+ }
107
+
108
+ if (exp.policyDimension) {
109
+ metricBuckets.policyDimension.total++;
110
+ if (actual.modelInterpretation.policyDimension === exp.policyDimension) {
111
+ metricBuckets.policyDimension.correct++;
112
+ } else {
113
+ eventCorrect = false;
114
+ }
115
+ }
116
+
117
+ if (exp.discussionType) {
118
+ metricBuckets.discussionType.total++;
119
+ if (actual.modelInterpretation.discussionType === exp.discussionType) {
120
+ metricBuckets.discussionType.correct++;
121
+ } else {
122
+ eventCorrect = false;
123
+ }
124
+ }
125
+
126
+ if (eventCorrect) eventsCorrect++;
127
+
128
+ if (exp.confidence !== undefined) {
129
+ totalConfidence += actual.modelInterpretation.confidence;
130
+ confidenceCount++;
131
+ }
132
+ }
133
+ }
134
+
135
+ const metrics: L2MetricScore[] = Object.entries(metricBuckets)
136
+ .filter(([, b]) => b.total > 0)
137
+ .map(([metric, b]) => ({
138
+ metric,
139
+ correct: b.correct,
140
+ total: b.total,
141
+ accuracy: b.total > 0 ? Math.round((b.correct / b.total) * 10000) / 100 : 0,
142
+ }));
143
+
144
+ return {
145
+ provider: config.provider,
146
+ model: config.model ?? "default",
147
+ metrics,
148
+ avgConfidence: confidenceCount > 0 ? Math.round((totalConfidence / confidenceCount) * 100) / 100 : 0,
149
+ overallAccuracy: totalEvents > 0 ? Math.round((eventsCorrect / totalEvents) * 10000) / 100 : 0,
150
+ totalEvents,
151
+ };
152
+ }
153
+
154
+ export function printBenchmarkResult(result: L2BenchmarkResult): void {
155
+ console.log(`\n=== L2 Quality Benchmarks ===`);
156
+ console.log(`Test cases: ${result.testCases} (${result.totalEvents} events)`);
157
+ console.log(`Providers: ${result.providers.length}`);
158
+ console.log();
159
+
160
+ for (const p of result.providers) {
161
+ console.log(`── ${p.provider}/${p.model} ──`);
162
+ if (p.totalEvents === 0) {
163
+ console.log(" (skipped — no results)");
164
+ continue;
165
+ }
166
+ console.log(` Overall accuracy: ${p.overallAccuracy}%`);
167
+ console.log(` Avg confidence: ${p.avgConfidence}`);
168
+ for (const m of p.metrics) {
169
+ console.log(` ${m.metric}: ${m.correct}/${m.total} (${m.accuracy}%)`);
170
+ }
171
+ console.log();
172
+ }
173
+ }
174
+
175
+ function makeEvent(overrides: Partial<EvidenceEvent> = {}): EvidenceEvent {
176
+ return {
177
+ eventType: "claim_first_seen",
178
+ fromRevisionId: 1,
179
+ toRevisionId: 2,
180
+ section: "lead",
181
+ before: "",
182
+ after: "",
183
+ deterministicFacts: [],
184
+ layer: "observed",
185
+ timestamp: "2024-01-01T00:00:00Z",
186
+ ...overrides,
187
+ };
188
+ }
189
+
190
+ export function buildL2Dataset(): L2TestCase[] {
191
+ return [
192
+ {
193
+ id: "simple-claim-add",
194
+ description: "A new factual claim appears in the lead section",
195
+ events: [
196
+ makeEvent({
197
+ eventType: "claim_first_seen",
198
+ section: "lead",
199
+ after: "Earth is the third planet from the Sun",
200
+ deterministicFacts: [{ fact: "claim_detected", detail: "sentence_length=42" }],
201
+ }),
202
+ ],
203
+ expected: [
204
+ {
205
+ eventIndex: 0,
206
+ semanticChange: "factual claim introduced",
207
+ policyDimension: "verifiability",
208
+ },
209
+ ],
210
+ },
211
+ {
212
+ id: "claim-removal",
213
+ description: "A claim is removed between revisions",
214
+ events: [
215
+ makeEvent({
216
+ eventType: "claim_removed",
217
+ section: "body",
218
+ before: "Some scientists believe the theory is flawed",
219
+ deterministicFacts: [{ fact: "claim_removed", detail: "sentence_length=42" }],
220
+ }),
221
+ ],
222
+ expected: [
223
+ {
224
+ eventIndex: 0,
225
+ semanticChange: "factual claim removed",
226
+ policyDimension: "verifiability",
227
+ },
228
+ ],
229
+ },
230
+ {
231
+ id: "claim-softened",
232
+ description: "A claim is softened with hedging language",
233
+ events: [
234
+ makeEvent({
235
+ eventType: "claim_softened",
236
+ section: "body",
237
+ before: "The update resolves the outage",
238
+ after: "The update may reduce the outage",
239
+ deterministicFacts: [{ fact: "claim_changed", detail: "change=softened" }],
240
+ }),
241
+ ],
242
+ expected: [
243
+ {
244
+ eventIndex: 0,
245
+ semanticChange: "claim softened with hedging",
246
+ policyDimension: "npov",
247
+ },
248
+ ],
249
+ },
250
+ {
251
+ id: "claim-strengthened",
252
+ description: "A claim is strengthened with more definitive language",
253
+ events: [
254
+ makeEvent({
255
+ eventType: "claim_strengthened",
256
+ section: "body",
257
+ before: "The event may have occurred in 1920",
258
+ after: "The event occurred in 1920",
259
+ deterministicFacts: [{ fact: "claim_changed", detail: "change=strengthened" }],
260
+ }),
261
+ ],
262
+ expected: [
263
+ {
264
+ eventIndex: 0,
265
+ semanticChange: "claim strengthened with definitive language",
266
+ },
267
+ ],
268
+ },
269
+ {
270
+ id: "citation-added",
271
+ description: "A citation is added to support a claim",
272
+ events: [
273
+ makeEvent({
274
+ eventType: "citation_added",
275
+ section: "body",
276
+ after: '<ref name="smith2023">{{cite journal |title=Study}}</ref>',
277
+ deterministicFacts: [{ fact: "citation_changed", detail: "type=added" }],
278
+ }),
279
+ ],
280
+ expected: [
281
+ {
282
+ eventIndex: 0,
283
+ semanticChange: "citation added to support claim",
284
+ policyDimension: "verifiability",
285
+ },
286
+ ],
287
+ },
288
+ {
289
+ id: "citation-removed",
290
+ description: "A citation is removed from a claim",
291
+ events: [
292
+ makeEvent({
293
+ eventType: "citation_removed",
294
+ section: "body",
295
+ before: '<ref name="old2020">{{cite web |title=Old}}</ref>',
296
+ deterministicFacts: [{ fact: "citation_changed", detail: "type=removed" }],
297
+ }),
298
+ ],
299
+ expected: [
300
+ {
301
+ eventIndex: 0,
302
+ semanticChange: "citation removed from article",
303
+ policyDimension: "verifiability",
304
+ },
305
+ ],
306
+ },
307
+ {
308
+ id: "template-npov",
309
+ description: "A POV template is added, indicating neutrality concern",
310
+ events: [
311
+ makeEvent({
312
+ eventType: "template_added",
313
+ section: "body",
314
+ after: "POV",
315
+ deterministicFacts: [
316
+ { fact: "template_changed", detail: "name=POV type=added" },
317
+ { fact: "policy_signal", detail: "dimension=npov signal=pov" },
318
+ ],
319
+ layer: "policy_coded",
320
+ }),
321
+ ],
322
+ expected: [
323
+ {
324
+ eventIndex: 0,
325
+ semanticChange: "neutrality concern template added",
326
+ policyDimension: "npov",
327
+ },
328
+ ],
329
+ },
330
+ {
331
+ id: "blp-template",
332
+ description: "A BLP template is added to a biography",
333
+ events: [
334
+ makeEvent({
335
+ eventType: "template_added",
336
+ section: "body",
337
+ after: "BLP sources",
338
+ deterministicFacts: [
339
+ { fact: "template_changed", detail: "name=BLP sources type=added" },
340
+ { fact: "policy_signal", detail: "dimension=blp signal=blp_sources" },
341
+ ],
342
+ layer: "policy_coded",
343
+ }),
344
+ ],
345
+ expected: [
346
+ {
347
+ eventIndex: 0,
348
+ semanticChange: "BLP sourcing concern template added",
349
+ policyDimension: "blp",
350
+ },
351
+ ],
352
+ },
353
+ {
354
+ id: "revert-detected",
355
+ description: "A revert is detected in edit war",
356
+ events: [
357
+ makeEvent({
358
+ eventType: "revert_detected",
359
+ section: "",
360
+ after: "Undid revision 123456 by UserX",
361
+ deterministicFacts: [
362
+ { fact: "revert_detected", detail: "Undid revision 123456 by UserX" },
363
+ { fact: "policy_signal", detail: "dimension=edit_warring signal=revert_detected" },
364
+ ],
365
+ layer: "policy_coded",
366
+ }),
367
+ ],
368
+ expected: [
369
+ {
370
+ eventIndex: 0,
371
+ semanticChange: "revert detected indicating edit warring",
372
+ policyDimension: "edit_warring",
373
+ },
374
+ ],
375
+ },
376
+ {
377
+ id: "talk-sourcing-dispute",
378
+ description: "Talk page discussion about sourcing",
379
+ events: [
380
+ makeEvent({
381
+ eventType: "talk_page_correlated",
382
+ section: "Sources",
383
+ deterministicFacts: [{ fact: "talk_revision_match", detail: "type=discussion" }],
384
+ }),
385
+ ],
386
+ expected: [
387
+ {
388
+ eventIndex: 0,
389
+ semanticChange: "talk page discussion about sources",
390
+ discussionType: "sourcing_dispute",
391
+ },
392
+ ],
393
+ },
394
+ {
395
+ id: "talk-notability",
396
+ description: "Talk page discussion about notability",
397
+ events: [
398
+ makeEvent({
399
+ eventType: "talk_page_correlated",
400
+ section: "Notability",
401
+ deterministicFacts: [{ fact: "talk_revision_match", detail: "type=discussion" }],
402
+ }),
403
+ ],
404
+ expected: [
405
+ {
406
+ eventIndex: 0,
407
+ semanticChange: "talk page discussion about notability",
408
+ discussionType: "notability_challenge",
409
+ },
410
+ ],
411
+ },
412
+ {
413
+ id: "category-change",
414
+ description: "Category added, changing page classification",
415
+ events: [
416
+ makeEvent({
417
+ eventType: "category_added",
418
+ section: "",
419
+ after: "Living people",
420
+ deterministicFacts: [{ fact: "category_added", detail: "category=Living people" }],
421
+ }),
422
+ ],
423
+ expected: [
424
+ {
425
+ eventIndex: 0,
426
+ semanticChange: "category added to page classification",
427
+ },
428
+ ],
429
+ },
430
+ {
431
+ id: "protection-change",
432
+ description: "Page protection level changed",
433
+ events: [
434
+ makeEvent({
435
+ eventType: "protection_changed",
436
+ section: "",
437
+ after: "protect",
438
+ deterministicFacts: [
439
+ { fact: "protection_changed", detail: "name=pp-protect type=added" },
440
+ { fact: "policy_signal", detail: "dimension=protection signal=page_protected" },
441
+ ],
442
+ layer: "policy_coded",
443
+ }),
444
+ ],
445
+ expected: [
446
+ {
447
+ eventIndex: 0,
448
+ semanticChange: "page protection level changed",
449
+ policyDimension: "protection",
450
+ },
451
+ ],
452
+ },
453
+ ];
454
+ }