From c04e6b539778a4aa82187916ca0385d6d53cd9d2 Mon Sep 17 00:00:00 2001 From: Giovanni Giovanni Date: Sat, 22 Aug 2026 23:23:27 -0700 Subject: [PATCH] DEM-BIND (a'): the residual builder + composer, adapter byte-untouched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEM-GOV §9 slot DEM-BIND, owner-authorized 2026-08-22. Step (a') of the D-DEM-2(5) merge order - referenced by nothing; no runtime consumes it yet. analysts/froggy.residual_builder.ts (NEW - the adapter is NOT edited): - FroggyResidualInput: the six fields the mapping cannot express (the five DEM-PRODUCER-reserved placeholders + liquiditySwept, the two-lane read D-DEM-3(5) expressly reserves). - buildFroggyResidualInput: calls the untouched legacy adapter export and picks the six residual fields; the expressible outputs are DISCARDED. On the live mapping path the fragment is unconditionally authoritative - a discarded value is not a fallback (D-DEM-2(6) defense in the header). - composeFroggyTrendPullbackInput: fragment + residual -> scorer input, fail-closed (D-DEM-5(2)): exactly the four declared targets, declared types, triggerPatternQuality narrowed to the D-EQ-2 grade union 0|1|2|3. analysts/__tests__/froggy.residual_builder.test.ts: 20-probe full-domain sweep proving compose(interpret(mapping, view).fragment, residual(view)) byte-equals the legacy adapter's full output - every band boundary, every recode branch, grandfather firings, absent/null namespaces, null-valued sources, sweep-hint variation - plus composer fail-closed cases. Gate: froggy.enrichment_adapter.ts byte-identical (empty diff); adapter test + FLPR guards untouched; 302 tests green; 0.1875 KAT anchor green. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/froggy.residual_builder.test.ts | 173 ++++++++++++++++++ analysts/froggy.residual_builder.ts | 129 +++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 analysts/__tests__/froggy.residual_builder.test.ts create mode 100644 analysts/froggy.residual_builder.ts diff --git a/analysts/__tests__/froggy.residual_builder.test.ts b/analysts/__tests__/froggy.residual_builder.test.ts new file mode 100644 index 0000000..04ec4ca --- /dev/null +++ b/analysts/__tests__/froggy.residual_builder.test.ts @@ -0,0 +1,173 @@ +/** + * DEM-BIND (a'): the residual builder + composer. + * + * The load-bearing property: for every enriched view, + * compose(interpret(registeredMapping, view).fragment, residual(view)) + * byte-equals the untouched legacy adapter's FULL output. This is the + * equivalence DEM-BIND's runtime seam relies on, proven here at the afi-core + * layer against the same adapter export the FLPR-GOV guards pin. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { describe, it, expect } from "vitest"; +import { + buildFroggyResidualInput, + composeFroggyTrendPullbackInput, +} from "../froggy.residual_builder.js"; +import { + buildFroggyTrendPullbackInputFromEnriched, + type FroggyEnrichedView, +} from "../froggy.enrichment_adapter.js"; +import { interpretEnrichmentMapping } from "../../validators/EnrichmentMappingInterpreter.js"; + +const SIBLING_MAPPING = new URL( + "../../../afi-config/examples/enrichment-mapping/v1/enrichment-mapping.example.json", + import.meta.url +).pathname; + +/** Inline copy of the canonical froggy mapping's bindings (byte-locked to the + * afi-config example by the DEM-CONTRACT interpreter test's sibling describe; + * duplicated minimally here so this suite stays hermetic). */ +function froggyMapping(): Record { + if (existsSync(SIBLING_MAPPING)) { + return JSON.parse(readFileSync(SIBLING_MAPPING, "utf8")); + } + return { + schema: "afi.enrichment-mapping.v1", + mappingId: "froggy-trend-pullback", + version: "1.0.0", + namespaceDefaults: ["technical", "pattern"], + bindings: { + distanceFromDailyEmaPct: { + operator: "bind", + source: { lane: "technical", path: "emaDistancePct" }, + type: "number", + optionality: { ground: "grandfather", default: 0 }, + }, + pulledBackIntoSweetSpot: { + operator: "bind", + source: { lane: "technical", path: "isInValueSweetSpot" }, + type: "boolean", + optionality: { ground: "grandfather", default: false }, + }, + triggerPatternQuality: { + operator: "band", + source: { lane: "pattern", path: "patternConfidence" }, + rows: [ + { when: { gte: 75 }, value: 3 }, + { when: { gte: 65 }, value: 2 }, + { when: { gt: 0 }, value: 1 }, + ], + otherwise: 0, + absent: 0, + }, + atrRegime: { + operator: "recode", + source: { lane: "technical", path: "atrRegime" }, + table: { low: "low", high: "high", extreme: "extreme" }, + fallback: "normal", + absent: "normal", + }, + }, + }; +} + +function view(overrides: Partial): FroggyEnrichedView { + return { + signalId: "sig-residual-test", + symbol: "BTCUSDT", + market: "crypto", + timeframe: "4h", + technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low" }, + pattern: { patternName: "bull flag", patternConfidence: 80 }, + sentiment: { score: 0.4, tags: ["liquidity sweep"] }, + ...overrides, + }; +} + +/** The full-domain probe set: every adapter branch the mapping expresses, + * plus residual-relevant variation (sweep hints on/off). */ +const PROBES: Array<[string, FroggyEnrichedView]> = [ + ["baseline", view({})], + ["band 75", view({ pattern: { patternConfidence: 75 } })], + ["band 74.999", view({ pattern: { patternConfidence: 74.999 } })], + ["band 65", view({ pattern: { patternConfidence: 65 } })], + ["band 0.5", view({ pattern: { patternConfidence: 0.5 } })], + ["band present-zero", view({ pattern: { patternConfidence: 0 } })], + ["band absent", view({ pattern: { patternName: "bull flag" } })], + ["recode high", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "high" } })], + ["recode extreme", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "extreme" } })], + ["recode present-normal", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "normal" } })], + ["recode unrecognized", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "weird" } })], + ["recode absent", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true } })], + ["grandfathers fire (empty technical)", view({ technical: {} })], + ["technical namespace missing", (() => { const v = view({}); delete (v as Record).technical; return v; })()], + ["pattern namespace missing", (() => { const v = view({}); delete (v as Record).pattern; return v; })()], + ["null namespace", view({ technical: null } as unknown as Partial)], + ["null-valued sources", view({ technical: { emaDistancePct: null, isInValueSweetSpot: null, atrRegime: null } } as unknown as Partial)], + ["sweep hints off", view({ sentiment: { score: 0.1, tags: [] }, pattern: { patternName: "doji", patternConfidence: 50 } })], + ["stop-hunt hint", view({ sentiment: { score: 0.1, tags: ["stop hunt"] } })], + ["negative distance out of sweet spot", view({ technical: { emaDistancePct: -3.2, isInValueSweetSpot: false, atrRegime: "low" } })], +]; + +describe("DEM-BIND (a'): compose(fragment, residual) === legacy adapter, byte-for-byte", () => { + for (const [label, v] of PROBES) { + it(label, () => { + const mapping = froggyMapping(); + const result = interpretEnrichmentMapping(mapping, v); + const residual = buildFroggyResidualInput(v); + const composed = composeFroggyTrendPullbackInput(result.fragment, residual); + const legacy = buildFroggyTrendPullbackInputFromEnriched(v); + expect(composed).toStrictEqual(legacy); + }); + } + + it("the residual carries exactly the six unexpressible fields", () => { + const residual = buildFroggyResidualInput(view({})); + expect(Object.keys(residual).sort()).toEqual([ + "brokeEmaWithBody", + "dailyBias", + "haFlatBackConfirmed", + "liquiditySwept", + "rrMultiplePlanned", + "weeklyBias", + ]); + }); +}); + +describe("DEM-BIND (a'): the composer fails closed (D-DEM-5(2))", () => { + const residual = buildFroggyResidualInput(view({})); + + it("refuses a fragment missing a declared target", () => { + expect(() => + composeFroggyTrendPullbackInput( + { distanceFromDailyEmaPct: 1, pulledBackIntoSweetSpot: true, triggerPatternQuality: 2 }, + residual + ) + ).toThrow(/exactly/); + }); + + it("refuses a fragment carrying an extra target", () => { + expect(() => + composeFroggyTrendPullbackInput( + { distanceFromDailyEmaPct: 1, pulledBackIntoSweetSpot: true, triggerPatternQuality: 2, atrRegime: "low", extra: 1 }, + residual + ) + ).toThrow(/exactly/); + }); + + it("refuses wrong-typed targets, including an out-of-grade integer", () => { + expect(() => + composeFroggyTrendPullbackInput( + { distanceFromDailyEmaPct: "1", pulledBackIntoSweetSpot: true, triggerPatternQuality: 2, atrRegime: "low" }, + residual + ) + ).toThrow(/number/); + expect(() => + composeFroggyTrendPullbackInput( + { distanceFromDailyEmaPct: 1, pulledBackIntoSweetSpot: true, triggerPatternQuality: 7, atrRegime: "low" }, + residual + ) + ).toThrow(/0\|1\|2\|3/); + }); +}); diff --git a/analysts/froggy.residual_builder.ts b/analysts/froggy.residual_builder.ts new file mode 100644 index 0000000..7ca981a --- /dev/null +++ b/analysts/froggy.residual_builder.ts @@ -0,0 +1,129 @@ +/** + * DEM-BIND: the residual (unexpressible) input builder + the composer that + * assembles the froggy scorer input from an interpreted mapping fragment plus + * the residual half (DEM-GOV D-DEM-2(6)(7); §9 slot DEM-BIND, owner-authorized + * 2026-08-22). + * + * WHY THIS MODULE EXISTS, AND WHY THE ADAPTER IS NOT EDITED: + * `froggy.enrichment_adapter.ts` stays byte-identical — the DEM-BIND gate + * requires the FLPR-GOV inertness guards and the oracle/byte-equivalence + * suites (which import the adapter export as their oracle) to pass UNCHANGED, + * and the owner directive requires the unexpressible half left in place. So + * the residual builder CALLS the untouched adapter and picks only the six + * fields the mapping cannot express (D-DEM-3): the five placeholders whose + * real producers are the DEM-PRODUCER slots, plus liquiditySwept (a two-lane + * read, inexpressible by construction — D-DEM-3(5), expressly reserved). + * + * On the live mapping path the adapter's expressible computation still + * executes here and its expressible outputs are DISCARDED — only the + * interpreter fragment is authoritative (D-DEM-2(6): "no code-path fallback + * to the retired bespoke code" — a discarded value is not a fallback; where a + * determination exists the fragment is unconditionally authoritative, and an + * interpreter refusal means no determination at all, D-DEM-5(7)). + */ + +import type { FroggyEnrichedView } from "./froggy.enrichment_adapter.js"; +import { buildFroggyTrendPullbackInputFromEnriched } from "./froggy.enrichment_adapter.js"; +import type { FroggyTrendPullbackInput } from "./froggy.trend_pullback_v1.js"; +import type { EnrichmentMappingResult } from "../validators/EnrichmentMappingInterpreter.js"; + +/** The six fields the mapping cannot express (D-DEM-3; DEM-PRODUCER-reserved + * placeholders + the reserved two-lane liquiditySwept). */ +export type FroggyResidualInput = Pick< + FroggyTrendPullbackInput, + | "weeklyBias" + | "dailyBias" + | "haFlatBackConfirmed" + | "brokeEmaWithBody" + | "rrMultiplePlanned" + | "liquiditySwept" +>; + +/** The four targets the registered froggy mapping declares (D-DEM-3(4)). */ +const EXPRESSIBLE_TARGETS = [ + "distanceFromDailyEmaPct", + "pulledBackIntoSweetSpot", + "triggerPatternQuality", + "atrRegime", +] as const; + +type ExpressibleTarget = (typeof EXPRESSIBLE_TARGETS)[number]; + +/** + * Build the residual half by running the untouched legacy adapter and picking + * the six unexpressible fields from its output. The expressible outputs are + * discarded (see the module header for the D-DEM-2(6) defense). + */ +export function buildFroggyResidualInput( + enriched: FroggyEnrichedView +): FroggyResidualInput { + const full = buildFroggyTrendPullbackInputFromEnriched(enriched); + return { + weeklyBias: full.weeklyBias, + dailyBias: full.dailyBias, + haFlatBackConfirmed: full.haFlatBackConfirmed, + brokeEmaWithBody: full.brokeEmaWithBody, + rrMultiplePlanned: full.rrMultiplePlanned, + liquiditySwept: full.liquiditySwept, + }; +} + +/** + * Compose the scorer input from an interpreted mapping fragment plus the + * residual half. Fail-closed (D-DEM-5(2)): the fragment must carry EXACTLY + * the four declared targets with the declared types — anything else throws; + * no defaulted, partial, or fabricated input ever reaches the scorer. + */ +export function composeFroggyTrendPullbackInput( + fragment: EnrichmentMappingResult["fragment"], + residual: FroggyResidualInput +): FroggyTrendPullbackInput { + const keys = Object.keys(fragment).sort(); + const expected = [...EXPRESSIBLE_TARGETS].sort(); + if ( + keys.length !== expected.length || + !expected.every((k, i) => keys[i] === k) + ) { + throw new Error( + `composeFroggyTrendPullbackInput: fragment must carry exactly ` + + `[${expected.join(", ")}], got [${keys.join(", ")}] — refusing to ` + + `compose (fail-closed, D-DEM-5(2)).` + ); + } + const distanceFromDailyEmaPct = fragment["distanceFromDailyEmaPct" satisfies ExpressibleTarget]; + const pulledBackIntoSweetSpot = fragment["pulledBackIntoSweetSpot" satisfies ExpressibleTarget]; + const triggerPatternQuality = fragment["triggerPatternQuality" satisfies ExpressibleTarget]; + const atrRegime = fragment["atrRegime" satisfies ExpressibleTarget]; + if (typeof distanceFromDailyEmaPct !== "number") { + throw new Error( + `composeFroggyTrendPullbackInput: distanceFromDailyEmaPct must be a number, got ${typeof distanceFromDailyEmaPct} — refusing (D-DEM-5(2)).` + ); + } + if (typeof pulledBackIntoSweetSpot !== "boolean") { + throw new Error( + `composeFroggyTrendPullbackInput: pulledBackIntoSweetSpot must be a boolean, got ${typeof pulledBackIntoSweetSpot} — refusing (D-DEM-5(2)).` + ); + } + if ( + triggerPatternQuality !== 0 && + triggerPatternQuality !== 1 && + triggerPatternQuality !== 2 && + triggerPatternQuality !== 3 + ) { + throw new Error( + `composeFroggyTrendPullbackInput: triggerPatternQuality must be 0|1|2|3 (the D-EQ-2 grade set), got ${String(triggerPatternQuality)} — refusing (D-DEM-5(2)).` + ); + } + if (typeof atrRegime !== "string") { + throw new Error( + `composeFroggyTrendPullbackInput: atrRegime must be a string, got ${typeof atrRegime} — refusing (D-DEM-5(2)).` + ); + } + return { + ...residual, + distanceFromDailyEmaPct, + pulledBackIntoSweetSpot, + triggerPatternQuality, + atrRegime: atrRegime as FroggyTrendPullbackInput["atrRegime"], + }; +}