diff --git a/services/compute/src/alert-scoring.ts b/services/compute/src/alert-scoring.ts new file mode 100644 index 0000000..83dc1ef --- /dev/null +++ b/services/compute/src/alert-scoring.ts @@ -0,0 +1,17 @@ +import type { ClassifierHitEvent, FlowPacket } from "@islandflow/types"; + +export const scoreAlert = ( + packet: FlowPacket, + hits: ClassifierHitEvent[] +): { score: number; severity: string } => { + const premium = + typeof packet.features.total_premium === "number" ? packet.features.total_premium : 0; + const premiumScore = Math.min(70, Math.round(premium / 1000)); + const maxConfidence = hits.reduce((max, hit) => Math.max(max, hit.confidence), 0); + const confidenceScore = Math.round(maxConfidence * 20); + const hitScore = Math.min(20, hits.length * 5); + const score = Math.max(0, Math.min(100, premiumScore + confidenceScore + hitScore)); + const severity = score >= 80 ? "high" : score >= 45 ? "medium" : "low"; + return { score, severity }; +}; + diff --git a/services/compute/src/index.ts b/services/compute/src/index.ts index 9ac8732..ebe4b24 100644 --- a/services/compute/src/index.ts +++ b/services/compute/src/index.ts @@ -75,6 +75,7 @@ import { shouldEmitStructurePacket, type LegEvidence } from "./structure-packets"; +import { scoreAlert } from "./alert-scoring"; const service = "compute"; const logger = createLogger({ service }); @@ -797,18 +798,6 @@ const flushCluster = async ( }); }; -const scoreAlert = (packet: FlowPacket, hits: ClassifierHitEvent[]): { score: number; severity: string } => { - const premium = - typeof packet.features.total_premium === "number" ? packet.features.total_premium : 0; - const premiumScore = Math.min(70, Math.round(premium / 1000)); - const maxConfidence = hits.reduce((max, hit) => Math.max(max, hit.confidence), 0); - const confidenceScore = Math.round(maxConfidence * 20); - const hitScore = Math.min(20, hits.length * 5); - const score = Math.max(0, Math.min(100, premiumScore + confidenceScore + hitScore)); - const severity = score >= 80 ? "high" : score >= 45 ? "medium" : "low"; - return { score, severity }; -}; - const emitClassifiers = async ( clickhouse: ReturnType, js: Awaited>["js"], diff --git a/services/compute/tests/alert-scoring.test.ts b/services/compute/tests/alert-scoring.test.ts new file mode 100644 index 0000000..7e9dc11 --- /dev/null +++ b/services/compute/tests/alert-scoring.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "bun:test"; +import type { ClassifierHitEvent } from "@islandflow/types"; +import { scoreAlert } from "../src/alert-scoring"; +import { buildFlowPacket } from "./helpers"; + +const hit = (confidence: number): ClassifierHitEvent => + ({ + source_ts: 1, + ingest_ts: 1, + seq: 1, + trace_id: `hit:${confidence}`, + classifier_id: "test", + confidence, + direction: "neutral", + explanations: ["test"] + }) satisfies ClassifierHitEvent; + +describe("alert scoring", () => { + it("classifies <45 as low", () => { + const packet = buildFlowPacket({ + features: { + total_premium: 44_000 + } + }); + + const result = scoreAlert(packet, []); + expect(result.score).toBe(44); + expect(result.severity).toBe("low"); + }); + + it("classifies >=45 as medium", () => { + const packet = buildFlowPacket({ + features: { + total_premium: 45_000 + } + }); + + const result = scoreAlert(packet, []); + expect(result.score).toBe(45); + expect(result.severity).toBe("medium"); + }); + + it("classifies >=80 as high", () => { + const packet = buildFlowPacket({ + features: { + total_premium: 65_000 + } + }); + + const result = scoreAlert(packet, [hit(0.5)]); + expect(result.score).toBe(80); + expect(result.severity).toBe("high"); + }); + + it("keeps 79 as medium", () => { + const packet = buildFlowPacket({ + features: { + total_premium: 64_000 + } + }); + + const result = scoreAlert(packet, [hit(0.5)]); + expect(result.score).toBe(79); + expect(result.severity).toBe("medium"); + }); +}); diff --git a/services/compute/tests/classifiers.test.ts b/services/compute/tests/classifiers.test.ts index 2446197..a160624 100644 --- a/services/compute/tests/classifiers.test.ts +++ b/services/compute/tests/classifiers.test.ts @@ -1,249 +1,296 @@ -import { describe, expect, test } from "bun:test"; -import type { FlowPacket } from "@islandflow/types"; -import { evaluateClassifiers, type ClassifierConfig } from "../src/classifiers"; +import { describe, expect, it } from "bun:test"; +import { evaluateClassifiers } from "../src/classifiers"; +import { buildFlowPacket, getHit, TEST_CLASSIFIER_CONFIG } from "./helpers"; -const baseConfig: ClassifierConfig = { - sweepMinPremium: 40_000, - sweepMinCount: 3, - sweepMinPremiumZ: 2, - spikeMinPremium: 20_000, - spikeMinSize: 400, - spikeMinPremiumZ: 2.5, - spikeMinSizeZ: 2, - zMinSamples: 12, - minNbboCoverage: 0.5, - minAggressorRatio: 0.55, - zeroDteMaxAtmPct: 0.01, - zeroDteMinPremium: 20_000, - zeroDteMinSize: 400 +const expectExplainable = (hit: NonNullable>) => { + expect(hit.confidence).toBeGreaterThanOrEqual(0); + expect(hit.confidence).toBeLessThanOrEqual(1); + expect(hit.direction.length).toBeGreaterThan(0); + expect(hit.explanations.length).toBeGreaterThan(0); + expect(hit.explanations.join(" ")).toMatch(/Likely|Consistent with|Unusual/i); }; -const DEFAULT_TS = Date.UTC(2024, 0, 2); - -const buildPacket = ( - overrides: Record -): FlowPacket => { - return { - source_ts: DEFAULT_TS, - ingest_ts: DEFAULT_TS, - seq: 1, - trace_id: "trace", - id: "packet", - members: ["m1"], - features: { - option_contract_id: "SPY-2025-01-17-450-C", - count: 3, - total_premium: 1000, - total_size: 20, - first_price: 1, - last_price: 1.01, - start_ts: DEFAULT_TS - 500, - end_ts: DEFAULT_TS, - window_ms: 500, - ...overrides - }, - join_quality: {} - }; -}; - -describe("classifier z-score behavior", () => { - test("spike hit triggers on z-score even when absolute thresholds fail", () => { - const packet = buildPacket({ - total_premium_z: 3.2, - total_premium_baseline_n: 20, - total_size_z: 0.4, - total_size_baseline_n: 20 +describe("compute classifiers", () => { + it("detects large bullish call sweep", () => { + const packet = buildFlowPacket({ + id: "flowpacket:sweep-call", + features: { + option_contract_id: "SPY-2025-02-01-450-C", + count: 5, + window_ms: 500, + total_size: 1200, + total_premium: 85_000, + first_price: 1.0, + last_price: 1.05, + nbbo_coverage_ratio: 0.9, + nbbo_aggressive_buy_ratio: 0.65, + nbbo_aggressive_sell_ratio: 0.15 + } }); - const hits = evaluateClassifiers(packet, baseConfig); - expect(hits.some((hit) => hit.classifier_id === "unusual_contract_spike")).toBe(true); + + const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG); + const hit = getHit(hits, "large_bullish_call_sweep"); + expect(hit).not.toBeNull(); + expect(hit?.direction).toBe("bullish"); + expectExplainable(hit!); }); - test("sweep hit triggers on premium z-score when baseline is ready", () => { - const packet = buildPacket({ - total_premium_z: 2.4, - total_premium_baseline_n: 20 + it("detects large bearish put sweep", () => { + const packet = buildFlowPacket({ + id: "flowpacket:sweep-put", + features: { + option_contract_id: "SPY-2025-02-01-450-P", + count: 4, + window_ms: 420, + total_size: 900, + total_premium: 60_000, + first_price: 2.0, + last_price: 2.15, + nbbo_coverage_ratio: 0.85, + nbbo_aggressive_buy_ratio: 0.2, + nbbo_aggressive_sell_ratio: 0.7 + } }); - const hits = evaluateClassifiers(packet, baseConfig); - expect(hits.some((hit) => hit.classifier_id === "large_bullish_call_sweep")).toBe(true); + + const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG); + const hit = getHit(hits, "large_bearish_put_sweep"); + expect(hit).not.toBeNull(); + expect(hit?.direction).toBe("bearish"); + expectExplainable(hit!); }); - test("sweep hit does not trigger when baseline is insufficient", () => { - const packet = buildPacket({ - total_premium_z: 3, - total_premium_baseline_n: 4 + it("detects unusual contract spike", () => { + const packet = buildFlowPacket({ + id: "flowpacket:spike", + features: { + option_contract_id: "NVDA-2025-02-21-600-C", + count: 2, + window_ms: 200, + total_size: 520, + total_premium: 30_000, + nbbo_coverage_ratio: 0.6, + nbbo_aggressive_buy_ratio: 0.6, + nbbo_aggressive_sell_ratio: 0.1 + } }); - const hits = evaluateClassifiers(packet, baseConfig); - expect(hits.some((hit) => hit.classifier_id === "large_bullish_call_sweep")).toBe(false); + + const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG); + const hit = getHit(hits, "unusual_contract_spike"); + expect(hit).not.toBeNull(); + expect(hit?.direction).toBe("neutral"); + expectExplainable(hit!); }); - test("aggressor mix adjusts sweep confidence", () => { - const basePacket = { - total_premium: 120_000, - total_size: 900, - count: 4, - nbbo_coverage_ratio: 0.8 - }; - - const lowAgg = buildPacket({ - ...basePacket, - nbbo_aggressive_buy_ratio: 0.2, - nbbo_aggressive_sell_ratio: 0.2 - }); - const highAgg = buildPacket({ - ...basePacket, - nbbo_aggressive_buy_ratio: 0.7, - nbbo_aggressive_sell_ratio: 0.3 + it("detects large call sell overwrite (sell-side skew)", () => { + const packet = buildFlowPacket({ + id: "flowpacket:overwrite", + features: { + option_contract_id: "AAPL-2025-02-21-200-C", + count: 3, + window_ms: 300, + total_size: 900, + total_premium: 35_000, + nbbo_coverage_ratio: 0.75, + nbbo_aggressive_buy_ratio: 0.1, + nbbo_aggressive_sell_ratio: 0.75 + } }); - const lowHit = evaluateClassifiers(lowAgg, baseConfig).find( - (hit) => hit.classifier_id === "large_bullish_call_sweep" - ); - const highHit = evaluateClassifiers(highAgg, baseConfig).find( - (hit) => hit.classifier_id === "large_bullish_call_sweep" - ); + const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG); + const hit = getHit(hits, "large_call_sell_overwrite"); + expect(hit).not.toBeNull(); + expect(hit?.direction).toBe("bearish"); + expectExplainable(hit!); + }); - expect(lowHit).toBeTruthy(); - expect(highHit).toBeTruthy(); - expect((highHit?.confidence ?? 0)).toBeGreaterThan(lowHit?.confidence ?? 0); + it("detects large put sell write (sell-side skew)", () => { + const packet = buildFlowPacket({ + id: "flowpacket:put-write", + features: { + option_contract_id: "AAPL-2025-02-21-200-P", + count: 3, + window_ms: 300, + total_size: 850, + total_premium: 32_000, + nbbo_coverage_ratio: 0.75, + nbbo_aggressive_buy_ratio: 0.1, + nbbo_aggressive_sell_ratio: 0.72 + } + }); + + const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG); + const hit = getHit(hits, "large_put_sell_write"); + expect(hit).not.toBeNull(); + expect(hit?.direction).toBe("bullish"); + expectExplainable(hit!); + }); + + it("detects far-dated conviction (>=60 DTE)", () => { + const packet = buildFlowPacket({ + id: "flowpacket:far-dated", + source_ts: Date.parse("2025-01-01T14:30:00Z"), + features: { + option_contract_id: "SPY-2025-04-10-450-C", + count: 2, + window_ms: 250, + total_size: 650, + total_premium: 28_000, + nbbo_coverage_ratio: 0.7, + nbbo_aggressive_buy_ratio: 0.6, + nbbo_aggressive_sell_ratio: 0.2 + } + }); + + const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG); + const hit = getHit(hits, "far_dated_conviction"); + expect(hit).not.toBeNull(); + expect(hit?.direction).toBe("bullish"); + expectExplainable(hit!); + }); + + it("detects 0DTE gamma punch when expiry matches packet day and near ATM", () => { + const packet = buildFlowPacket({ + id: "flowpacket:zero-dte", + source_ts: Date.parse("2025-01-17T15:30:00Z"), + features: { + option_contract_id: "SPY-2025-01-17-450-C", + count: 3, + window_ms: 350, + total_size: 800, + total_premium: 50_000, + underlying_mid: 450.5, + nbbo_coverage_ratio: 0.8, + nbbo_aggressive_buy_ratio: 0.65, + nbbo_aggressive_sell_ratio: 0.15 + } + }); + + const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG); + const hit = getHit(hits, "zero_dte_gamma_punch"); + expect(hit).not.toBeNull(); + expect(hit?.direction).toBe("bullish"); + expectExplainable(hit!); + }); + + it("detects structure straddle and strangle packets", () => { + const base = { + packet_kind: "structure", + structure_legs: 4, + structure_strikes: 2, + structure_strike_span: 5, + total_size: 600, + total_premium: 30_000, + nbbo_coverage_ratio: 0.7, + nbbo_aggressive_buy_ratio: 0.55, + nbbo_aggressive_sell_ratio: 0.35 + } as const; + + const straddlePacket = buildFlowPacket({ + id: "flowpacket:straddle", + features: { + ...base, + structure_type: "straddle" + } + }); + const stranglePacket = buildFlowPacket({ + id: "flowpacket:strangle", + features: { + ...base, + structure_type: "strangle", + structure_strike_span: 12 + } + }); + + const straddleHits = evaluateClassifiers(straddlePacket, TEST_CLASSIFIER_CONFIG); + const strangleHits = evaluateClassifiers(stranglePacket, TEST_CLASSIFIER_CONFIG); + + const straddleHit = getHit(straddleHits, "straddle"); + const strangleHit = getHit(strangleHits, "strangle"); + + expect(straddleHit).not.toBeNull(); + expect(strangleHit).not.toBeNull(); + expectExplainable(straddleHit!); + expectExplainable(strangleHit!); + }); + + it("detects vertical spread structure packets and infers direction from aggressor skew", () => { + const packet = buildFlowPacket({ + id: "flowpacket:vertical", + features: { + packet_kind: "structure", + structure_type: "vertical", + structure_rights: "C", + structure_legs: 4, + structure_strikes: 2, + structure_strike_span: 10, + total_size: 900, + total_premium: 45_000, + nbbo_coverage_ratio: 0.8, + nbbo_aggressive_buy_ratio: 0.7, + nbbo_aggressive_sell_ratio: 0.2 + } + }); + + const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG); + const hit = getHit(hits, "vertical_spread"); + expect(hit).not.toBeNull(); + expect(hit?.direction).toBe("bullish"); + expectExplainable(hit!); + }); + + it("detects ladder accumulation packets", () => { + const packet = buildFlowPacket({ + id: "flowpacket:ladder", + features: { + packet_kind: "structure", + structure_type: "ladder", + structure_rights: "C", + structure_legs: 6, + structure_strikes: 4, + structure_strike_span: 15, + total_size: 1200, + total_premium: 55_000, + nbbo_coverage_ratio: 0.65, + nbbo_aggressive_buy_ratio: 0.6, + nbbo_aggressive_sell_ratio: 0.2 + } + }); + + const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG); + const hit = getHit(hits, "ladder_accumulation"); + expect(hit).not.toBeNull(); + expect(hit?.direction).toBe("bullish"); + expectExplainable(hit!); + }); + + it("detects roll up/down/out structure packets", () => { + const packet = buildFlowPacket({ + id: "flowpacket:roll", + features: { + packet_kind: "structure", + structure_type: "roll", + structure_rights: "C", + underlying_id: "SPY", + roll_from_expiry: "2025-02-21", + roll_to_expiry: "2025-03-21", + roll_from_strike: 440, + roll_to_strike: 450, + roll_strike_delta: 10, + roll_expiry_days_delta: 28, + total_size: 700, + total_premium: 38_000, + nbbo_coverage_ratio: 0.7, + nbbo_aggressive_buy_ratio: 0.6, + nbbo_aggressive_sell_ratio: 0.3 + } + }); + + const hits = evaluateClassifiers(packet, TEST_CLASSIFIER_CONFIG); + const hit = getHit(hits, "roll_up_down_out"); + expect(hit).not.toBeNull(); + expect(hit?.direction).toBe("bullish"); + expectExplainable(hit!); + expect(hit!.explanations[0]).toMatch(/Consistent with/i); }); }); -describe("classifier structure and positioning signals", () => { - test("call overwrite triggers on sell-side aggressor mix", () => { - const packet = buildPacket({ - option_contract_id: "SPY-2024-03-15-450-C", - total_premium: 80_000, - total_size: 800, - nbbo_coverage_ratio: 0.9, - nbbo_aggressive_sell_ratio: 0.7, - nbbo_aggressive_buy_ratio: 0.3 - }); - const hits = evaluateClassifiers(packet, baseConfig); - expect(hits.some((hit) => hit.classifier_id === "large_call_sell_overwrite")).toBe(true); - }); - - test("put write triggers on sell-side aggressor mix", () => { - const packet = buildPacket({ - option_contract_id: "SPY-2024-03-15-450-P", - total_premium: 75_000, - total_size: 700, - nbbo_coverage_ratio: 0.85, - nbbo_aggressive_sell_ratio: 0.68, - nbbo_aggressive_buy_ratio: 0.32 - }); - const hits = evaluateClassifiers(packet, baseConfig); - expect(hits.some((hit) => hit.classifier_id === "large_put_sell_write")).toBe(true); - }); - - test("straddle classifier triggers on structure tag", () => { - const packet = buildPacket({ - packet_kind: "structure", - structure_type: "straddle", - structure_legs: 2, - structure_strikes: 1, - structure_rights: "C/P", - structure_strike_span: 0 - }); - const hits = evaluateClassifiers(packet, baseConfig); - expect(hits.some((hit) => hit.classifier_id === "straddle")).toBe(true); - }); - - test("structure classifiers are suppressed on per-contract packets", () => { - const packet = buildPacket({ - structure_type: "straddle", - structure_legs: 2, - structure_strikes: 1, - structure_rights: "C/P", - structure_strike_span: 0 - }); - const hits = evaluateClassifiers(packet, baseConfig); - expect(hits.some((hit) => hit.classifier_id === "straddle")).toBe(false); - }); - - test("vertical spread infers direction from aggressor skew", () => { - const packet = buildPacket({ - packet_kind: "structure", - structure_type: "vertical", - structure_legs: 2, - structure_strikes: 2, - structure_rights: "C", - structure_strike_span: 5, - total_premium: 55_000, - total_size: 600, - nbbo_coverage_ratio: 0.85, - nbbo_aggressive_buy_ratio: 0.7, - nbbo_aggressive_sell_ratio: 0.3 - }); - const hits = evaluateClassifiers(packet, baseConfig); - const hit = hits.find((candidate) => candidate.classifier_id === "vertical_spread"); - expect(hit?.direction).toBe("bullish"); - }); - - test("ladder accumulation triggers on multi-strike structures", () => { - const packet = buildPacket({ - packet_kind: "structure", - structure_type: "ladder", - structure_legs: 3, - structure_strikes: 3, - structure_rights: "C", - structure_strike_span: 10, - total_premium: 60_000, - total_size: 650 - }); - const hits = evaluateClassifiers(packet, baseConfig); - expect(hits.some((hit) => hit.classifier_id === "ladder_accumulation")).toBe(true); - }); - - test("roll classifier triggers on cross-expiry structure packets", () => { - const packet = buildPacket({ - packet_kind: "structure", - structure_type: "roll", - structure_legs: 2, - structure_strikes: 2, - structure_rights: "C", - structure_strike_span: 5, - total_premium: 70_000, - total_size: 800, - nbbo_coverage_ratio: 0.85, - nbbo_aggressive_buy_ratio: 0.7, - nbbo_aggressive_sell_ratio: 0.3, - roll_from_expiry: "2025-01-17", - roll_to_expiry: "2025-02-21", - roll_from_strike: 450, - roll_to_strike: 455, - roll_strike_delta: 5, - roll_expiry_days_delta: 35 - }); - const hits = evaluateClassifiers(packet, baseConfig); - const hit = hits.find((candidate) => candidate.classifier_id === "roll_up_down_out"); - expect(hit).toBeTruthy(); - expect(hit?.direction).toBe("bullish"); - }); - - test("far-dated conviction triggers on 60DTE threshold", () => { - const packet = buildPacket({ - option_contract_id: "SPY-2024-04-19-450-C", - end_ts: DEFAULT_TS, - total_premium: 70_000, - total_size: 800 - }); - const hits = evaluateClassifiers(packet, baseConfig); - const hit = hits.find((candidate) => candidate.classifier_id === "far_dated_conviction"); - expect(hit?.direction).toBe("bullish"); - }); - - test("zero dte gamma punch triggers when ATM and large", () => { - const packet = buildPacket({ - option_contract_id: "SPY-2024-01-02-450-C", - total_premium: 35_000, - total_size: 600, - underlying_mid: 450, - nbbo_coverage_ratio: 0.8, - nbbo_aggressive_buy_ratio: 0.7, - nbbo_aggressive_sell_ratio: 0.3 - }); - const hits = evaluateClassifiers(packet, baseConfig); - const hit = hits.find((candidate) => candidate.classifier_id === "zero_dte_gamma_punch"); - expect(hit?.direction).toBe("bullish"); - }); -}); diff --git a/services/compute/tests/helpers.ts b/services/compute/tests/helpers.ts new file mode 100644 index 0000000..ed7a494 --- /dev/null +++ b/services/compute/tests/helpers.ts @@ -0,0 +1,69 @@ +import type { ClassifierConfig, ClassifierHit } from "../src/classifiers"; +import type { FlowPacket } from "@islandflow/types"; + +export const TEST_CLASSIFIER_CONFIG: ClassifierConfig = { + sweepMinPremium: 40_000, + sweepMinCount: 3, + sweepMinPremiumZ: 2, + spikeMinPremium: 20_000, + spikeMinSize: 400, + spikeMinPremiumZ: 2.5, + spikeMinSizeZ: 2, + zMinSamples: 12, + minNbboCoverage: 0.5, + minAggressorRatio: 0.55, + zeroDteMaxAtmPct: 0.01, + zeroDteMinPremium: 20_000, + zeroDteMinSize: 400 +}; + +export const buildFlowPacket = (opts: { + id?: string; + source_ts?: number; + ingest_ts?: number; + seq?: number; + trace_id?: string; + members?: string[]; + features?: FlowPacket["features"]; + join_quality?: FlowPacket["join_quality"]; +} = {}): FlowPacket => { + const id = opts.id ?? "flowpacket:test"; + const source_ts = opts.source_ts ?? Date.parse("2025-01-01T14:30:00Z"); + const ingest_ts = opts.ingest_ts ?? source_ts; + const seq = opts.seq ?? 1; + const trace_id = opts.trace_id ?? `trace:${id}`; + + return { + source_ts, + ingest_ts, + seq, + trace_id, + id, + members: opts.members ?? ["print:1", "print:2"], + features: { + count: 1, + window_ms: 250, + total_premium: 0, + total_size: 0, + first_price: 0, + last_price: 0, + total_premium_z: 0, + total_size_z: 0, + total_premium_baseline_n: 0, + total_size_baseline_n: 0, + nbbo_coverage_ratio: 0, + nbbo_aggressive_buy_ratio: 0, + nbbo_aggressive_sell_ratio: 0, + underlying_mid: 0, + ...opts.features + }, + join_quality: { + ...opts.join_quality + } + }; +}; + +export const getHit = (hits: ClassifierHit[], id: string): ClassifierHit | null => { + return hits.find((hit) => hit.classifier_id === id) ?? null; +}; +