diff --git a/analysts/__tests__/froggy.enrichment_adapter.test.ts b/analysts/__tests__/froggy.enrichment_adapter.test.ts index 87884b0..e46823a 100644 --- a/analysts/__tests__/froggy.enrichment_adapter.test.ts +++ b/analysts/__tests__/froggy.enrichment_adapter.test.ts @@ -1,13 +1,28 @@ import { describe, it, expect } from "vitest"; import { - scoreFroggyTrendPullbackFromEnriched, + scoreFroggyTrendPullback, type FroggyTrendPullbackScore } from "../froggy.trend_pullback_v1"; +import { defaultUwrConfig } from "../../validators/UniversalWeightingRule"; import { - BROKE_EMA_WITH_BODY_UNIMPLEMENTED_STUB, buildFroggyTrendPullbackInputFromEnriched, type FroggyEnrichedView } from "../froggy.enrichment_adapter"; +import { + buildFroggyResidualInput, + composeFroggyTrendPullbackInput +} from "../froggy.residual_builder"; +import { interpretEnrichmentMapping } from "../../validators/EnrichmentMappingInterpreter"; +import { NEWEST_REGISTERED_FROGGY_MAPPING } from "./support/froggyMappings"; + +/** The live scoring path (scorer node shape): registered mapping fragment + + * residual → composer → rubric. The adapter-only convenience wrapper left + * with DEM-PRODUCER-PLAN (the adapter no longer emits a full input). */ +function scoreViaMapping(enriched: FroggyEnrichedView): FroggyTrendPullbackScore { + const { fragment } = interpretEnrichmentMapping(NEWEST_REGISTERED_FROGGY_MAPPING(), enriched); + const input = composeFroggyTrendPullbackInput(fragment, buildFroggyResidualInput(enriched)); + return scoreFroggyTrendPullback(input, defaultUwrConfig, enriched); +} describe("froggy.enrichment_adapter", () => { it("produces a high-ish score for a strong enriched setup", () => { @@ -19,7 +34,14 @@ describe("froggy.enrichment_adapter", () => { technical: { emaDistancePct: 0.5, isInValueSweetSpot: true, - brokeEmaWithBody: false + // DEM-PRODUCER-CANDLE: the lane's computed candle-structure facts. + brokeEmaWithBody: false, + haFlatBack: "bullish", + haFlatBackConfirmed: true, + // DEM-PRODUCER-PLAN: a strong setup carries a verified plan; the + // risk axis reads the provider's R:R through the mapping (a plan-less + // submission scores the declared floor). + plan: { entryPrice: 100, stopPrice: 98, firstTargetPrice: 104, rrToFirstTarget: 2, targetCount: 1 } }, pattern: { patternName: "liquidity sweep + reversal", @@ -31,7 +53,7 @@ describe("froggy.enrichment_adapter", () => { }; const result: FroggyTrendPullbackScore = - scoreFroggyTrendPullbackFromEnriched(enriched); + scoreViaMapping(enriched); // All scoring data is now in analystScore (canonical) expect(result.analystScore.uwrAxes.structure).toBeGreaterThan(0.3); @@ -41,7 +63,7 @@ describe("froggy.enrichment_adapter", () => { expect(result.analystScore.uwrScore).toBeGreaterThan(0.3); }); - it("falls back to safe defaults when optional sections are missing", () => { + it("REFUSES when the technical lane is absent — the candle facts are required binds (D-DEM-5(2)); no safe default", () => { const enriched: FroggyEnrichedView = { signalId: "enriched-2", symbol: "ETH", @@ -49,15 +71,7 @@ describe("froggy.enrichment_adapter", () => { timeframe: "4h" // no technical/pattern/sentiment/news/aiMl }; - - const result = scoreFroggyTrendPullbackFromEnriched(enriched); - - // All scoring data is now in analystScore (canonical) - expect(result.analystScore.uwrAxes.structure).toBeDefined(); - expect(result.analystScore.uwrAxes.execution).toBeDefined(); - expect(result.analystScore.uwrAxes.risk).toBeDefined(); - expect(result.analystScore.uwrAxes.insight).toBeDefined(); - expect(result.analystScore.uwrScore).toBeDefined(); + expect(() => scoreViaMapping(enriched)).toThrow(/required-source-absent|brokeEmaWithBody/); }); // EQ-GOV D-EQ-2: the confidence -> triggerPatternQuality threshold map is @@ -162,15 +176,15 @@ describe("froggy.enrichment_adapter", () => { }); }); - it("uses the declared brokeEmaWithBody stub when technical omits the field (D5 zero-movement)", () => { - expect(BROKE_EMA_WITH_BODY_UNIMPLEMENTED_STUB).toBe(false); + it("emits neither brokeEmaWithBody nor haFlatBackConfirmed (DEM-PRODUCER-CANDLE: computed lane facts, mapping-bound)", () => { const input = buildFroggyTrendPullbackInputFromEnriched({ - signalId: "stub-broke", + signalId: "no-stub", symbol: "BTC", market: "crypto", timeframe: "1h", technical: { emaDistancePct: 0.5, isInValueSweetSpot: true } }); - expect(input.brokeEmaWithBody).toBe(BROKE_EMA_WITH_BODY_UNIMPLEMENTED_STUB); + expect(Object.keys(input)).not.toContain("brokeEmaWithBody"); + expect(Object.keys(input)).not.toContain("haFlatBackConfirmed"); }); }); diff --git a/analysts/__tests__/froggy.residual_builder.test.ts b/analysts/__tests__/froggy.residual_builder.test.ts index 04ec4ca..413728c 100644 --- a/analysts/__tests__/froggy.residual_builder.test.ts +++ b/analysts/__tests__/froggy.residual_builder.test.ts @@ -1,76 +1,47 @@ /** - * DEM-BIND (a'): the residual builder + composer. + * DEM-BIND (a') as generalized by DEM-PRODUCER-PLAN: the residual builder + + * composer. * - * The load-bearing property: for every enriched view, + * The load-bearing property (the equivalence the reactor's scorer node relies + * on): 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 + * equals the retired adapter's output on every field the adapter still + * computes, plus — for the fields whose producers have landed — the value the + * registered mapping binds from the lane fact (or its declared default when + * the producer legitimately emitted nothing). 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, + FROGGY_INPUT_FIELDS, } from "../froggy.residual_builder.js"; import { buildFroggyTrendPullbackInputFromEnriched, type FroggyEnrichedView, } from "../froggy.enrichment_adapter.js"; import { interpretEnrichmentMapping } from "../../validators/EnrichmentMappingInterpreter.js"; +import { + froggyMapping130, + loadRegisteredFroggyMapping, + NEWEST_REGISTERED_FROGGY_MAPPING_VERSION, +} from "./support/froggyMappings.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", - }, - }, - }; -} +const REGISTERED = loadRegisteredFroggyMapping(NEWEST_REGISTERED_FROGGY_MAPPING_VERSION, froggyMapping130); + +/** The technical lane's candle-structure facts (DEM-PRODUCER-CANDLE) every + * probe view carries — the mapping binds them as REQUIRED. */ +const CANDLE = { brokeEmaWithBody: false, haFlatBack: "none" as const, haFlatBackConfirmed: false }; +/** DEM-PRODUCER-HTF: the lane's higher-timeframe trend facts (recoded by the + * mapping into the rubric's bias vocabulary). */ +const HTF = { + htf: { + daily: { timeframe: "1d", trendBias: "bullish" as const, ema20: 101, ema50: 100, barCount: 100 }, + weekly: { timeframe: "1w", trendBias: "bullish" as const, ema20: 102, ema50: 100, barCount: 100 }, + }, +}; function view(overrides: Partial): FroggyEnrichedView { return { @@ -78,7 +49,7 @@ function view(overrides: Partial): FroggyEnrichedView { symbol: "BTCUSDT", market: "crypto", timeframe: "4h", - technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low" }, + technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", ...CANDLE, ...HTF }, pattern: { patternName: "bull flag", patternConfidence: 80 }, sentiment: { score: 0.4, tags: ["liquidity sweep"] }, ...overrides, @@ -86,88 +57,167 @@ function view(overrides: Partial): FroggyEnrichedView { } /** The full-domain probe set: every adapter branch the mapping expresses, - * plus residual-relevant variation (sweep hints on/off). */ + * residual-relevant variation (sweep hints on/off), and the PLAN producer's + * fact present / partial / absent. */ const PROBES: Array<[string, FroggyEnrichedView]> = [ - ["baseline", view({})], + ["baseline (no plan)", 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)], + ["recode high", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "high", ...CANDLE, ...HTF } })], + ["recode extreme", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "extreme", ...CANDLE, ...HTF } })], + ["recode present-normal", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "normal", ...CANDLE, ...HTF } })], + ["recode unrecognized", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "weird", ...CANDLE, ...HTF } })], + ["recode absent", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, ...CANDLE, ...HTF } })], + ["grandfathers fire (technical carries only the candle facts)", view({ technical: { ...CANDLE, ...HTF } })], + ["pattern namespace missing", (() => { const v = view({}); delete (v as Record).pattern; return v; })()], + ["null-valued sources", view({ technical: { emaDistancePct: null, isInValueSweetSpot: null, atrRegime: null, ...CANDLE, ...HTF } } 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" } })], + ["negative distance out of sweet spot", view({ technical: { emaDistancePct: -3.2, isInValueSweetSpot: false, atrRegime: "low", ...CANDLE, ...HTF } })], + ["plan present: rr 1.4286", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", ...CANDLE, ...HTF, plan: { entryPrice: 50000, stopPrice: 49300, firstTargetPrice: 51000, rrToFirstTarget: 1.4286, targetCount: 1 } } })], + ["plan present: rr 2.5", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", ...CANDLE, ...HTF, plan: { entryPrice: 100, stopPrice: 98, firstTargetPrice: 105, rrToFirstTarget: 2.5, targetCount: 2 } } })], + ["plan present but incomplete (entry only → no R:R fact)", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", ...CANDLE, ...HTF, plan: { entryPrice: 3001.5, targetCount: 0 } } })], + ["candle facts: broke + confirmed", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", brokeEmaWithBody: true, haFlatBack: "bullish", haFlatBackConfirmed: true, ...HTF } })], + ["candle facts: flat-back present but unconfirmed", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", brokeEmaWithBody: false, haFlatBack: "bearish", haFlatBackConfirmed: false, ...HTF } })], + ["htf: weekly bearish, daily bullish (conflict → the analyst's 'unknown' verdict)", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", ...CANDLE, htf: { daily: { timeframe: "1d", trendBias: "bullish" as const, ema20: 101, ema50: 100, barCount: 100 }, weekly: { timeframe: "1w", trendBias: "bearish" as const, ema20: 98, ema50: 100, barCount: 100 } } } })], + ["htf: range on both (neutral)", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", ...CANDLE, htf: { daily: { timeframe: "1d", trendBias: "range" as const, ema20: 100, ema50: 100, barCount: 100 }, weekly: { timeframe: "1w", trendBias: "range" as const, ema20: 100, ema50: 100, barCount: 100 } } } })], + ["htf: weekly window absent (declared producer absence → recode absent member)", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", ...CANDLE, htf: { daily: { timeframe: "1d", trendBias: "bullish" as const, ema20: 101, ema50: 100, barCount: 100 } } } })], + ["plan block null", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", ...CANDLE, ...HTF, plan: null } })], ]; -describe("DEM-BIND (a'): compose(fragment, residual) === legacy adapter, byte-for-byte", () => { +/** What the registered mapping must yield for the PLAN-produced field. */ +function expectedRr(v: FroggyEnrichedView): number { + const rr = v.technical?.plan?.rrToFirstTarget; + return typeof rr === "number" ? rr : 1; +} + +describe(`compose(fragment(${NEWEST_REGISTERED_FROGGY_MAPPING_VERSION}), residual) partitions the scorer input exactly`, () => { + it("the inline registered mapping is byte-content-identical to the sibling registry file (when present)", () => { + if (!REGISTERED.fromSibling) return; // hermetic checkout: inline copy is the vector + expect(REGISTERED.doc).toStrictEqual(froggyMapping130()); + }); + for (const [label, v] of PROBES) { it(label, () => { - const mapping = froggyMapping(); - const result = interpretEnrichmentMapping(mapping, v); + const result = interpretEnrichmentMapping(REGISTERED.doc, v); const residual = buildFroggyResidualInput(v); const composed = composeFroggyTrendPullbackInput(result.fragment, residual); - const legacy = buildFroggyTrendPullbackInputFromEnriched(v); - expect(composed).toStrictEqual(legacy); + const adapter = buildFroggyTrendPullbackInputFromEnriched(v); + const bias = (b?: string) => (b === "bullish" ? "long" : b === "bearish" ? "short" : "neutral"); + expect(composed).toStrictEqual({ + ...adapter, + rrMultiplePlanned: expectedRr(v), + // DEM-PRODUCER-CANDLE: the lane's computed facts, bound verbatim. + brokeEmaWithBody: v.technical!.brokeEmaWithBody, + haFlatBackConfirmed: v.technical!.haFlatBackConfirmed, + // DEM-PRODUCER-HTF: the lane's trend vocabulary, recoded by the mapping. + weeklyBias: bias(v.technical?.htf?.weekly?.trendBias ?? undefined), + dailyBias: bias(v.technical?.htf?.daily?.trendBias ?? undefined), + }); + // Every producer fact rides the mapping, never the adapter: the + // synthesis, the stub, and all three literals are deleted. + for (const gone of ["rrMultiplePlanned", "brokeEmaWithBody", "haFlatBackConfirmed", "weeklyBias", "dailyBias"]) { + expect(Object.keys(adapter)).not.toContain(gone); + } + // A missing R:R fact fires the declared default and is RECORDED. + const rrFired = result.firedDefaults.includes("rrMultiplePlanned"); + expect(rrFired).toBe(typeof v.technical?.plan?.rrToFirstTarget !== "number"); }); } - it("the residual carries exactly the six unexpressible fields", () => { + it("the residual carries ONLY liquiditySwept — the D-DEM-4(2) placeholder inventory is empty", () => { const residual = buildFroggyResidualInput(view({})); - expect(Object.keys(residual).sort()).toEqual([ + expect(Object.keys(residual)).toEqual(["liquiditySwept"]); + }); + + it("a view without the candle facts REFUSES at the interpreter (required binds; D-DEM-5(2)) — never a default", () => { + const bare = view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low" } }); + expect(() => interpretEnrichmentMapping(REGISTERED.doc, bare)).toThrow(/required-source-absent|brokeEmaWithBody/); + const noTechnical = view({}); delete (noTechnical as Record).technical; + expect(() => interpretEnrichmentMapping(REGISTERED.doc, noTechnical)).toThrow(/required-source-absent|brokeEmaWithBody/); + }); + + it("FROGGY_INPUT_FIELDS is the scorer input's complete field set", () => { + expect([...FROGGY_INPUT_FIELDS].sort()).toEqual([ + "atrRegime", "brokeEmaWithBody", "dailyBias", + "distanceFromDailyEmaPct", "haFlatBackConfirmed", "liquiditySwept", + "pulledBackIntoSweetSpot", "rrMultiplePlanned", + "triggerPatternQuality", "weeklyBias", ]); }); }); -describe("DEM-BIND (a'): the composer fails closed (D-DEM-5(2))", () => { +describe("the composer fails closed (D-DEM-5(2))", () => { const residual = buildFroggyResidualInput(view({})); + const goodFragment = { + distanceFromDailyEmaPct: 1, + pulledBackIntoSweetSpot: true, + triggerPatternQuality: 2, + atrRegime: "low", + rrMultiplePlanned: 2, + brokeEmaWithBody: false, + haFlatBackConfirmed: true, + weeklyBias: "long", + dailyBias: "long", + }; + + it("composes a complete partition", () => { + const out = composeFroggyTrendPullbackInput(goodFragment, residual); + expect(Object.keys(out).sort()).toEqual([...FROGGY_INPUT_FIELDS].sort()); + expect(out.rrMultiplePlanned).toBe(2); + }); - it("refuses a fragment missing a declared target", () => { + it("refuses a fragment missing a target the residual does not supply", () => { + const { rrMultiplePlanned: _rr, ...missingRr } = goodFragment; + expect(() => composeFroggyTrendPullbackInput(missingRr, residual)).toThrow(/exactly[\s\S]*missing \[rrMultiplePlanned\]/); + }); + + it("refuses a fragment carrying an unknown target", () => { expect(() => - composeFroggyTrendPullbackInput( - { distanceFromDailyEmaPct: 1, pulledBackIntoSweetSpot: true, triggerPatternQuality: 2 }, - residual - ) - ).toThrow(/exactly/); + composeFroggyTrendPullbackInput({ ...goodFragment, extra: 1 }, residual) + ).toThrow(/exactly[\s\S]*extra \[extra\]/); }); - it("refuses a fragment carrying an extra target", () => { + it("refuses a fragment that also supplies a residual field (no dual source)", () => { expect(() => - composeFroggyTrendPullbackInput( - { distanceFromDailyEmaPct: 1, pulledBackIntoSweetSpot: true, triggerPatternQuality: 2, atrRegime: "low", extra: 1 }, - residual - ) - ).toThrow(/exactly/); + composeFroggyTrendPullbackInput({ ...goodFragment, liquiditySwept: false }, residual) + ).toThrow(/both supply \[liquiditySwept\]/); }); - it("refuses wrong-typed targets, including an out-of-grade integer", () => { + it("refuses wrong-typed targets, including an out-of-grade integer and a non-finite number", () => { expect(() => - composeFroggyTrendPullbackInput( - { distanceFromDailyEmaPct: "1", pulledBackIntoSweetSpot: true, triggerPatternQuality: 2, atrRegime: "low" }, - residual - ) - ).toThrow(/number/); + composeFroggyTrendPullbackInput({ ...goodFragment, distanceFromDailyEmaPct: "1" }, residual) + ).toThrow(/distanceFromDailyEmaPct must be finite number/); expect(() => - composeFroggyTrendPullbackInput( - { distanceFromDailyEmaPct: 1, pulledBackIntoSweetSpot: true, triggerPatternQuality: 7, atrRegime: "low" }, - residual - ) + composeFroggyTrendPullbackInput({ ...goodFragment, triggerPatternQuality: 7 }, residual) ).toThrow(/0\|1\|2\|3/); + expect(() => + composeFroggyTrendPullbackInput({ ...goodFragment, rrMultiplePlanned: Number.NaN }, residual) + ).toThrow(/rrMultiplePlanned must be finite number/); + expect(() => + composeFroggyTrendPullbackInput({ ...goodFragment, atrRegime: "volatile" }, residual) + ).toThrow(/atrRegime must be low\|normal\|high\|extreme/); + }); + + it("refuses a residual value outside its domain (the residual is checked too)", () => { + expect(() => + composeFroggyTrendPullbackInput(goodFragment, { liquiditySwept: "yes" as never }) + ).toThrow(/liquiditySwept must be boolean/); + }); + + it("refuses a mapping-sourced bias outside the rubric's vocabulary", () => { + expect(() => + composeFroggyTrendPullbackInput({ ...goodFragment, weeklyBias: "sideways" }, residual) + ).toThrow(/weeklyBias must be long\|short\|neutral/); }); }); diff --git a/analysts/__tests__/froggy.trend_pullback_v1.test.ts b/analysts/__tests__/froggy.trend_pullback_v1.test.ts index 4253644..bd7244c 100644 --- a/analysts/__tests__/froggy.trend_pullback_v1.test.ts +++ b/analysts/__tests__/froggy.trend_pullback_v1.test.ts @@ -1,11 +1,28 @@ import { describe, expect, it } from "vitest"; import { scoreFroggyTrendPullback, - scoreFroggyTrendPullbackFromEnriched, type FroggyTrendPullbackInput } from "../froggy.trend_pullback_v1"; import { AnalystScoreTemplateSchema } from "../../src/analyst/AnalystScoreTemplate.js"; import type { FroggyEnrichedView } from "../froggy.enrichment_adapter.js"; +import { defaultUwrConfig } from "../../validators/UniversalWeightingRule"; +import { + buildFroggyResidualInput, + composeFroggyTrendPullbackInput +} from "../froggy.residual_builder"; +import { interpretEnrichmentMapping } from "../../validators/EnrichmentMappingInterpreter"; +import { NEWEST_REGISTERED_FROGGY_MAPPING } from "./support/froggyMappings"; + +/** The live composition (registered mapping fragment + residual → rubric); + * the adapter-only wrapper left with DEM-PRODUCER-PLAN. */ +function scoreViaMapping(enriched: FroggyEnrichedView) { + const { fragment } = interpretEnrichmentMapping(NEWEST_REGISTERED_FROGGY_MAPPING(), enriched); + return scoreFroggyTrendPullback( + composeFroggyTrendPullbackInput(fragment, buildFroggyResidualInput(enriched)), + defaultUwrConfig, + enriched + ); +} const baseGoodInput: FroggyTrendPullbackInput = { weeklyBias: "long", @@ -91,6 +108,8 @@ describe("Froggy trend_pullback_v1 analyst mapping", () => { emaDistancePct: 0.5, isInValueSweetSpot: true, brokeEmaWithBody: false, + haFlatBack: "none", + haFlatBackConfirmed: false, }, pattern: { patternName: "bullish_engulfing", @@ -98,7 +117,7 @@ describe("Froggy trend_pullback_v1 analyst mapping", () => { }, }; - const result = scoreFroggyTrendPullbackFromEnriched(enrichedView); + const result = scoreViaMapping(enrichedView); // Verify analystScore uses enriched view data expect(result.analystScore.baseAsset).toBe("ETH"); @@ -111,3 +130,44 @@ describe("Froggy trend_pullback_v1 analyst mapping", () => { }); }); +// --------------------------------------------------------------------------- +// DEM-PRODUCER-HTF: the analyst's own direction verdict, now reachable +// (DIR-GOV D-DIR-3's scope-guard required this branch's semantics to be +// resolved in the slot that makes non-neutral biases reachable). +// --------------------------------------------------------------------------- +describe("analystScore.direction — the analyst's verdict from real HTF biases", () => { + const withBiases = (weeklyBias: FroggyTrendPullbackInput["weeklyBias"], dailyBias: FroggyTrendPullbackInput["dailyBias"]) => + scoreFroggyTrendPullback({ ...baseGoodInput, weeklyBias, dailyBias }).analystScore.direction; + + it("both timeframes agree on a side → that side", () => { + expect(withBiases("long", "long")).toBe("long"); + expect(withBiases("short", "short")).toBe("short"); + }); + + it("either timeframe has no directional bias → neutral (the analyst asserts no direction)", () => { + expect(withBiases("neutral", "long")).toBe("neutral"); + expect(withBiases("long", "neutral")).toBe("neutral"); + expect(withBiases("neutral", "neutral")).toBe("neutral"); + }); + + it("both directional and DISAGREEING → 'unknown': a higher-timeframe CONFLICT, not an error or an absence", () => { + expect(withBiases("long", "short")).toBe("unknown"); + expect(withBiases("short", "long")).toBe("unknown"); + }); + + it("the verdict feeds no axis: only the aligned-bias structure term reads the biases, and it is computed before the verdict", () => { + const conflict = scoreFroggyTrendPullback({ ...baseGoodInput, weeklyBias: "long", dailyBias: "short" }); + const neutral = scoreFroggyTrendPullback({ ...baseGoodInput, weeklyBias: "neutral", dailyBias: "neutral" }); + // Neither pairing is aligned-and-non-neutral, so the +0.4 term fires for + // neither: the axes are identical and only the verdict differs. + expect(conflict.analystScore.uwrAxes).toEqual(neutral.analystScore.uwrAxes); + expect(conflict.analystScore.direction).not.toBe(neutral.analystScore.direction); + }); + + it("an aligned non-neutral pair is the ONLY pairing that lifts structure (+0.4)", () => { + const aligned = scoreFroggyTrendPullback({ ...baseGoodInput, weeklyBias: "long", dailyBias: "long" }).analystScore.uwrAxes.structure; + const conflict = scoreFroggyTrendPullback({ ...baseGoodInput, weeklyBias: "long", dailyBias: "short" }).analystScore.uwrAxes.structure; + expect(aligned).toBeGreaterThan(conflict); + expect(Math.round((aligned - conflict) * 100) / 100).toBe(0.4); + }); +}); diff --git a/analysts/__tests__/support/froggyMappings.ts b/analysts/__tests__/support/froggyMappings.ts new file mode 100644 index 0000000..f61ba02 --- /dev/null +++ b/analysts/__tests__/support/froggyMappings.ts @@ -0,0 +1,177 @@ +/** + * Test support: the REGISTERED froggy mapping versions, as data. + * + * Each `froggyMappingVXYZ()` is an inline, hermetic copy of the registry file + * `afi-config/registries/enrichment-mappings/froggy-trend-pullback--X.Y.Z.json`. + * When the sibling afi-config checkout is present, `loadRegisteredFroggyMapping` + * reads the registry file and the suite asserts the inline copy is + * byte-content-identical to it (a drift guard between the two repos); in a + * hermetic CI checkout the inline copy is used alone. + */ +import { existsSync, readFileSync } from "node:fs"; + +export function siblingMappingPath(version: string): string { + return new URL( + `../../../../afi-config/registries/enrichment-mappings/froggy-trend-pullback--${version}.json`, + import.meta.url + ).pathname; +} + +/** The DEM-BIND registration (four expressible bindings; D-DEM-5(4) grandfather). */ +export function froggyMapping100(): Record { + return { + schema: "afi.enrichment-mapping.v1", + mappingId: "froggy-trend-pullback", + version: "1.0.0", + description: + "The expressible half of the froggy trend_pullback_v1 enrichment adapter, exactly as performed today (froggy.enrichment_adapter.ts:218-276 at afi-core 1be76cd). The D-DEM-5(4) grandfather — four absent-source defaults, exhaustive and non-extensible — is carried as two optionality declarations (ground 'grandfather') plus the band's declared absent member and the recode's fallback/absent members.", + namespaceDefaults: ["technical", "pattern"], + bindings: expressibleBindings100(), + }; +} + +function expressibleBindings100(): Record { + return { + 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", + }, + }; +} + +/** DEM-PRODUCER-PLAN: 1.0.0 + rrMultiplePlanned bound to the technical lane's + * verified trade-plan fact (producer-declared optional, floor default 1). */ +export function froggyMapping110(): Record { + return { + schema: "afi.enrichment-mapping.v1", + mappingId: "froggy-trend-pullback", + version: "1.1.0", + description: + "DEM-PRODUCER-PLAN: the 1.0.0 bindings plus rrMultiplePlanned bound to technical.plan.rrToFirstTarget, the technical lane's VERIFIED trade-plan fact (the submitted afi.trade-plan.v1 levels checked against the fetched candles, D-DEM-5(6)). Optional under D-DEM-5(4)(b): the producer emits no R:R for a submission carrying no complete plan; the declared default is the rubric floor (1), hash-committed when fired (DEM-GOV §9 D-5).", + namespaceDefaults: ["technical", "pattern"], + bindings: { + ...expressibleBindings100(), + rrMultiplePlanned: { + operator: "bind", + source: { + lane: "technical", + path: "plan.rrToFirstTarget", + producedBy: { pluginId: "afi-analysis-technical", pluginVersion: "2.0.0" }, + }, + type: "number", + optionality: { + ground: "producer-declared", + default: 1, + producerRef: { pluginId: "afi-analysis-technical", pluginVersion: "2.0.0" }, + }, + }, + }, + }; +} + +/** DEM-PRODUCER-CANDLE: 1.1.0 + the two candle-structure facts as REQUIRED binds + * (no optionality — a window below the kernel floor refuses, D-DEM-5(2)). */ +export function froggyMapping120(): Record { + const base = froggyMapping110(); + return { + ...base, + version: "1.2.0", + description: + "DEM-PRODUCER-CANDLE: the 1.1.0 bindings plus brokeEmaWithBody and haFlatBackConfirmed bound to the technical lane's COMPUTED candle-structure facts (the latest bar's body closed on the counter-trend side of EMA20; the Heikin-Ashi flat-back agreeing with the lane's trend law) — required binds: a window below the 50-bar kernel floor emits no technical payload and the determination refuses (D-DEM-5(2)). The D5-GOV stub and the haFlatBackConfirmed literal are retired.", + bindings: { + ...(base.bindings as Record), + brokeEmaWithBody: { + operator: "bind", + source: { + lane: "technical", + path: "brokeEmaWithBody", + producedBy: { pluginId: "afi-analysis-technical", pluginVersion: "2.0.0" }, + }, + type: "boolean", + }, + haFlatBackConfirmed: { + operator: "bind", + source: { + lane: "technical", + path: "haFlatBackConfirmed", + producedBy: { pluginId: "afi-analysis-technical", pluginVersion: "2.0.0" }, + }, + type: "boolean", + }, + }, + }; +} + +/** DEM-PRODUCER-HTF: 1.2.0 + weeklyBias/dailyBias RECODED from the technical + * lane's higher-timeframe trend facts. The vocabulary translation + * (bullish→long, bearish→short, range→neutral) lives in the MAPPING, never in + * the producer (D-DEM-3: no mapping-shaped constant in a producer); `absent` + * is a structurally required recode member and fires only on a genuine + * producer absence (a window below the kernel floor). */ +export function froggyMapping130(): Record { + const base = froggyMapping120(); + const recode = (path: string) => ({ + operator: "recode", + source: { + lane: "technical", + path, + producedBy: { pluginId: "afi-analysis-technical", pluginVersion: "2.0.0" }, + }, + table: { bullish: "long", bearish: "short", range: "neutral" }, + fallback: "neutral", + absent: "neutral", + }); + return { + ...base, + version: "1.3.0", + description: + "DEM-PRODUCER-HTF: the 1.2.0 bindings plus weeklyBias and dailyBias recoded from the technical lane's higher-timeframe trend facts (technical.htf.weekly.trendBias / technical.htf.daily.trendBias), computed by the same EMA law over separately fetched windows whose timeframes are a registered composition value. The vocabulary translation lives here, not in the producer (D-DEM-3). A window below the kernel floor is a declared producer absence and fires the recode's absent member.", + bindings: { + ...(base.bindings as Record), + weeklyBias: recode("htf.weekly.trendBias"), + dailyBias: recode("htf.daily.trendBias"), + }, + }; +} + +/** The newest registered mapping this afi-core revision composes against. */ +export const NEWEST_REGISTERED_FROGGY_MAPPING = froggyMapping130; +export const NEWEST_REGISTERED_FROGGY_MAPPING_VERSION = "1.3.0"; + +/** Prefer the sibling registry file when present (and let the suite drift-check it). */ +export function loadRegisteredFroggyMapping(version: string, inline: () => Record): { + doc: Record; + fromSibling: boolean; +} { + const path = siblingMappingPath(version); + if (existsSync(path)) { + return { doc: JSON.parse(readFileSync(path, "utf8")) as Record, fromSibling: true }; + } + return { doc: inline(), fromSibling: false }; +} diff --git a/analysts/froggy.enrichment_adapter.ts b/analysts/froggy.enrichment_adapter.ts index e81d24f..e2f244b 100644 --- a/analysts/froggy.enrichment_adapter.ts +++ b/analysts/froggy.enrichment_adapter.ts @@ -14,10 +14,18 @@ // guards, the oracle golden harness, and the interpreter equivalence // suites all import it read-only to prove the mapping path reproduces // it exactly over the full non-refusal domain; -// 2. the callee of froggy.residual_builder.ts, which Picks only the six +// 2. the callee of froggy.residual_builder.ts, which Picks only the still- // UNEXPRESSIBLE fields (D-DEM-3) and discards the expressible outputs. -// Physical deletion would re-home those guards and is reserved to a future -// owner-gated act — outside every DEM-GOV slot. +// DEM-PRODUCER-PLAN (owner-authorized 2026-08-25) deleted the rrMultiplePlanned +// synthesis: the planned R:R is a PROVIDER fact the technical lane produces +// from the submitted trade plan (verified against the fetched candles, +// D-DEM-5(6)) and the registered mapping binds. DEM-PRODUCER-CANDLE deleted +// BROKE_EMA_WITH_BODY_UNIMPLEMENTED_STUB and the `haFlatBackConfirmed: false` +// literal, and DEM-PRODUCER-HTF deleted the weeklyBias/dailyBias neutral +// literals: all five are computed lane facts the registered mapping binds. +// NOTHING this builder returns is a placeholder any more — its output is +// FroggyAdapterOutput: the four expressible fields plus the reserved +// liquiditySwept (D-DEM-3(5)). The D-DEM-4(2) inventory is empty. import type { FroggyTrendPullbackInput } from "./froggy.trend_pullback_v1.js"; @@ -54,16 +62,6 @@ export interface FroggyAiMlV1 { notes?: string | null; } -/** - * Declared stub for `brokeEmaWithBody` until a modelling filing implements a - * candle-derived producer (D5 / D5-GOV zero-movement option). - * - * Live reactor `viewTechnical` pins this exact value. It is **not** a silent - * missing-data default: it is the explicit, unimplemented-input law. Changing - * it, or wiring a real producer, is a score-moving Tier-F act. - */ -export const BROKE_EMA_WITH_BODY_UNIMPLEMENTED_STUB = false as const; - export interface FroggyEnrichedView { signalId: string; symbol: string; @@ -73,7 +71,16 @@ export interface FroggyEnrichedView { technical?: { emaDistancePct?: number | null; isInValueSweetSpot?: boolean | null; + /** + * DEM-PRODUCER-CANDLE: the technical lane's COMPUTED candle-structure + * facts (the D5-GOV stub is retired): the latest bar's body closed on the + * counter-trend side of EMA20; the Heikin-Ashi flat-back side and its + * agreement with the lane's trend law. Read only through the registered + * mapping (required binds); this adapter never reads them. + */ brokeEmaWithBody?: boolean | null; + haFlatBack?: "bullish" | "bearish" | "none" | null; + haFlatBackConfirmed?: boolean | null; /** * Raw indicator readings projected from the technical lane * (rsi / ema_20 / ema_50 / volume_ratio). Context only — NOT read by this @@ -115,6 +122,39 @@ export interface FroggyEnrichedView { * vocabulary; absent or unrecognized maps to "normal". */ atrRegime?: "low" | "normal" | "high" | "extreme" | null; + /** + * DEM-PRODUCER-PLAN: the technical lane's VERIFIED trade-plan facts, + * projected from `technical.plan` — a provider fact (the submitted + * afi.trade-plan.v1 levels, checked against the candles the lane fetched; + * D-DEM-5(6)). Absent when the submission carried no plan. `rrToFirstTarget` + * is absent when the plan carries no stop or no target (declared producer + * absence, D-DEM-5(4)(b)). Read only through the registered mapping; this + * adapter never reads it. + */ + /** + * DEM-PRODUCER-HTF: the technical lane's higher-timeframe bias facts, + * computed by the SAME EMA20/EMA50 trend law over separately fetched + * daily/weekly windows (their timeframes are a registered composition + * value, never a code constant). A sub-block is absent when its window is + * below the kernel floor (declared producer absence). Read only through + * the registered mapping, which recodes the lane's trend vocabulary into + * the rubric's bias vocabulary; this adapter never reads it, and no + * submitted direction reaches it (DIR-GOV D-DIR-3). + */ + htf?: { + daily?: { timeframe: string; trendBias: "bullish" | "bearish" | "range"; ema20: number; ema50: number; barCount: number } | null; + weekly?: { timeframe: string; trendBias: "bullish" | "bearish" | "range"; ema20: number; ema50: number; barCount: number } | null; + } | null; + plan?: { + entryPrice?: number | null; + stopPrice?: number | null; + firstTargetPrice?: number | null; + rrToFirstTarget?: number | null; + targetCount?: number | null; + envelopeLow?: number | null; + envelopeHigh?: number | null; + barCount?: number | null; + } | null; }; pattern?: { @@ -236,19 +276,26 @@ const quantisePatternConfidence = (confidence: number): 0 | 1 | 2 | 3 => { * the retirement note in this file's header. Kept byte-identical as the * test-side byte-equivalence oracle and the residual builder's callee. */ +/** + * What this retired builder still emits: every scorer input EXCEPT the ones + * whose producers have landed — DEM-PRODUCER-PLAN (`rrMultiplePlanned`) and + * DEM-PRODUCER-CANDLE (`brokeEmaWithBody`, `haFlatBackConfirmed`) are + * mapping-bound lane facts and are never stubbed or synthesized here again. + */ +export type FroggyAdapterOutput = Omit< + FroggyTrendPullbackInput, + "rrMultiplePlanned" | "brokeEmaWithBody" | "haFlatBackConfirmed" | "weeklyBias" | "dailyBias" +>; + export function buildFroggyTrendPullbackInputFromEnriched( enriched: FroggyEnrichedView -): FroggyTrendPullbackInput { +): FroggyAdapterOutput { const technical = enriched.technical ?? {}; const pattern = enriched.pattern ?? {}; const sentiment = enriched.sentiment ?? {}; const distanceFromDailyEmaPct = technical.emaDistancePct ?? 0; const pulledBackIntoSweetSpot = technical.isInValueSweetSpot ?? false; - // Explicit stub law (not a silent default): absent/undefined → declared - // unimplemented stub. A future producer must land via a score-moving filing. - const brokeEmaWithBody = - technical.brokeEmaWithBody ?? BROKE_EMA_WITH_BODY_UNIMPLEMENTED_STUB; const triggerPatternQuality = pattern.patternConfidence != null @@ -275,23 +322,11 @@ export function buildFroggyTrendPullbackInputFromEnriched( ? technical.atrRegime : "normal"; - // HTF bias defaults neutral (could be extended when HTF context is available). - const weeklyBias = "neutral" as const; - const dailyBias = "neutral" as const; - - const rrMultiplePlanned = - pulledBackIntoSweetSpot && !brokeEmaWithBody ? 2 : 1; - return { - weeklyBias, - dailyBias, - haFlatBackConfirmed: false, // TODO: map from enriched technical context when available distanceFromDailyEmaPct, pulledBackIntoSweetSpot, - brokeEmaWithBody, liquiditySwept, triggerPatternQuality, atrRegime, - rrMultiplePlanned, // conservative default; TODO: map from enriched risk cues if present }; } diff --git a/analysts/froggy.residual_builder.ts b/analysts/froggy.residual_builder.ts index ffeafa0..d830920 100644 --- a/analysts/froggy.residual_builder.ts +++ b/analysts/froggy.residual_builder.ts @@ -2,17 +2,19 @@ * 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). + * 2026-08-22). Generalized by DEM-PRODUCER-PLAN (owner-authorized 2026-08-25). * - * 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). + * WHY THIS MODULE EXISTS, AND WHY THE ADAPTER IS NOT DELETED: + * `froggy.enrichment_adapter.ts` survives as the test-side oracle the FLPR-GOV + * inertness guards import, and as the callee that still computes the + * placeholders whose producers have not landed yet. The residual builder CALLS + * it and picks ONLY the fields the registered mapping cannot express (D-DEM-3): + * `liquiditySwept` alone — a two-lane read, inexpressible by construction + * (D-DEM-3(5), expressly reserved). rrMultiplePlanned left with + * DEM-PRODUCER-PLAN, brokeEmaWithBody + haFlatBackConfirmed with + * DEM-PRODUCER-CANDLE, and weeklyBias + dailyBias with DEM-PRODUCER-HTF: + * every one of them is now a registered producer's fact that the registered + * mapping binds. * * On the live mapping path the adapter's expressible computation still * executes here and its expressible outputs are DISCARDED — only the @@ -21,12 +23,14 @@ * determination exists the fragment is unconditionally authoritative, and an * interpreter refusal means no determination at all, D-DEM-5(7)). * - * SINCE THE FINAL BOUNDED STEP (mappingRef required; DEM-BIND (e2)): the - * reactor's scorer node has NO legacy branch — it refuses to score without a - * resolved mapping, so this composer is the ONLY assembly of the froggy - * scorer input at runtime. The adapter export's remaining consumers are the - * test-side byte-equivalence oracles and this module's residual Pick — see - * the retirement note in froggy.enrichment_adapter.ts (D-DEM-2(7), ruling R1). + * THE COMPOSER IS MAPPING-VERSION-AGNOSTIC AND FAIL-CLOSED (D-DEM-5(2)): the + * fragment (whatever the registered mapping version binds) and the residual + * (whatever is still unexpressible) must partition the scorer input's field set + * EXACTLY — no field missing, none supplied twice — and every value must lie in + * its field's declared domain. Anything else refuses: no defaulted, partial, or + * fabricated input ever reaches the scorer. The domain table below is + * predicate-valued on purpose: no scorer-input field is ever assigned a + * literal in this module (DEM-PLACEHOLDER-GUARD bound). */ import type { FroggyEnrichedView } from "./froggy.enrichment_adapter.js"; @@ -34,103 +38,128 @@ import { buildFroggyTrendPullbackInputFromEnriched } from "./froggy.enrichment_a 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 ONLY field the mapping cannot express (D-DEM-3(5)): `liquiditySwept`, + * a two-lane read whose disposition is expressly reserved to its own filing. + * Every other scorer input is now a registered producer's fact bound by the + * registered mapping — the D-DEM-4(2) placeholder inventory is empty. */ +export type FroggyResidualInput = Pick; -/** The four targets the registered froggy mapping declares (D-DEM-3(4)). */ -const EXPRESSIBLE_TARGETS = [ +type FroggyInputField = keyof FroggyTrendPullbackInput; + +/** The scorer input's complete field set — the partition the composer enforces. */ +export const FROGGY_INPUT_FIELDS: readonly FroggyInputField[] = Object.freeze([ + "weeklyBias", + "dailyBias", + "haFlatBackConfirmed", "distanceFromDailyEmaPct", "pulledBackIntoSweetSpot", + "brokeEmaWithBody", + "liquiditySwept", "triggerPatternQuality", "atrRegime", -] as const; + "rrMultiplePlanned", +] as const); + +type DomainPredicate = (value: unknown) => boolean; + +function isBoolean(value: unknown): boolean { + return typeof value === "boolean"; +} +function isFiniteNumber(value: unknown): boolean { + return typeof value === "number" && Number.isFinite(value); +} +function isBias(value: unknown): boolean { + return value === "long" || value === "short" || value === "neutral"; +} +function isGrade(value: unknown): boolean { + return value === 0 || value === 1 || value === 2 || value === 3; +} +function isAtrRegime(value: unknown): boolean { + return value === "low" || value === "normal" || value === "high" || value === "extreme"; +} -type ExpressibleTarget = (typeof EXPRESSIBLE_TARGETS)[number]; +/** Per-field declared domain (the rubric's input contract, froggy.trend_pullback_v1.ts:21-35). */ +const FIELD_DOMAIN: Readonly> = + Object.freeze({ + weeklyBias: { describe: "long|short|neutral", test: isBias }, + dailyBias: { describe: "long|short|neutral", test: isBias }, + haFlatBackConfirmed: { describe: "boolean", test: isBoolean }, + distanceFromDailyEmaPct: { describe: "finite number", test: isFiniteNumber }, + pulledBackIntoSweetSpot: { describe: "boolean", test: isBoolean }, + brokeEmaWithBody: { describe: "boolean", test: isBoolean }, + liquiditySwept: { describe: "boolean", test: isBoolean }, + triggerPatternQuality: { describe: "0|1|2|3 (the D-EQ-2 grade set)", test: isGrade }, + atrRegime: { describe: "low|normal|high|extreme", test: isAtrRegime }, + rrMultiplePlanned: { describe: "finite number", test: isFiniteNumber }, + }); /** * Build the residual half by running the untouched legacy adapter and picking - * the six unexpressible fields from its output. The expressible outputs are + * the still-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, - }; + return { liquiditySwept: full.liquiditySwept }; +} + +function refuse(detail: string): never { + throw new Error( + `composeFroggyTrendPullbackInput: ${detail} — refusing to compose (fail-closed, D-DEM-5(2)).` + ); } /** * 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. + * residual half. Fail-closed (D-DEM-5(2)): fragment and residual must + * partition the scorer input's field set exactly, and every value must lie + * in its declared domain — 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)).` - ); + const fragmentKeys = Object.keys(fragment); + const residualKeys = Object.keys(residual); + const expected = [...FROGGY_INPUT_FIELDS].sort(); + + const overlap = fragmentKeys.filter((k) => residualKeys.includes(k)); + if (overlap.length > 0) { + refuse(`fragment and residual both supply [${overlap.sort().join(", ")}]`); } - 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)).` + const union = [...fragmentKeys, ...residualKeys].sort(); + if (union.length !== expected.length || !expected.every((k, i) => union[i] === k)) { + const missing = expected.filter((k) => !union.includes(k)); + const extra = union.filter((k) => !(expected as string[]).includes(k)); + refuse( + `fragment ∪ residual must carry exactly [${expected.join(", ")}]; ` + + `missing [${missing.join(", ")}], extra [${extra.join(", ")}]` ); } - if (typeof atrRegime !== "string") { - throw new Error( - `composeFroggyTrendPullbackInput: atrRegime must be a string, got ${typeof atrRegime} — refusing (D-DEM-5(2)).` - ); + + const assembled: Record = {}; + for (const field of FROGGY_INPUT_FIELDS) { + const fromFragment = Object.prototype.hasOwnProperty.call(fragment, field); + const value = fromFragment + ? (fragment as Record)[field] + : (residual as Record)[field]; + const domain = FIELD_DOMAIN[field]; + if (!domain.test(value)) { + refuse( + `${field} must be ${domain.describe}, got ${describeValue(value)} ` + + `(${fromFragment ? "mapping fragment" : "residual"})` + ); + } + assembled[field] = value; } - return { - ...residual, - distanceFromDailyEmaPct, - pulledBackIntoSweetSpot, - triggerPatternQuality, - atrRegime: atrRegime as FroggyTrendPullbackInput["atrRegime"], - }; + return assembled as unknown as FroggyTrendPullbackInput; +} + +function describeValue(value: unknown): string { + if (typeof value === "string") return `string ${JSON.stringify(value)}`; + if (typeof value === "number") return `number ${String(value)}`; + return typeof value; } diff --git a/analysts/froggy.trend_pullback_v1.ts b/analysts/froggy.trend_pullback_v1.ts index 88d866d..dafda27 100644 --- a/analysts/froggy.trend_pullback_v1.ts +++ b/analysts/froggy.trend_pullback_v1.ts @@ -5,7 +5,6 @@ import { type UwrAxesInput } from "../validators/UniversalWeightingRule.js"; import type { FroggyEnrichedView } from "./froggy.enrichment_adapter.js"; -import { buildFroggyTrendPullbackInputFromEnriched } from "./froggy.enrichment_adapter.js"; import { type AnalystScoreTemplate, AnalystScoreTemplateSchema @@ -187,7 +186,26 @@ function buildAnalystScoreTemplate( // record shape; do not treat them as separable quality numbers. const conviction: number = uwrScore; - // Derive direction from bias + // The ANALYST'S OWN verdict, derived from the analyst's higher-timeframe + // bias inputs — never from the submitted side (DIR-GOV D-DIR-3; the + // submitted side rides scoredSignal.direction, and disagreement between the + // two is signal, not defect). + // + // DEM-PRODUCER-HTF resolves the "unknown" branch DIR-GOV's scope-guard + // reserved (its semantics could not be settled while both biases were + // hardcoded "neutral" and the branch was unreachable). With real HTF facts + // the four cases are exhaustive and each means something: + // long / short — both timeframes agree on a non-neutral side; + // neutral — at least one timeframe has NO directional bias (a range + // regime, or a window the producer could not compute), so + // the analyst asserts no direction; + // unknown — both timeframes are directional and DISAGREE (weekly up + // vs daily down, or the reverse): a higher-timeframe + // CONFLICT. It is not an error and not an absence — it is + // the analyst declining to call a direction while the + // timeframes contradict each other. It feeds no axis (the + // axes are computed above), and it never reaches + // scoredSignal.direction, which carries the submitted side. const direction: "long" | "short" | "neutral" | "unknown" = input.weeklyBias === "long" && input.dailyBias === "long" ? "long" : input.weeklyBias === "short" && input.dailyBias === "short" ? "short" : @@ -308,10 +326,3 @@ export function scoreFroggyTrendPullback( notes: notes.length ? notes : undefined, }; } - -export function scoreFroggyTrendPullbackFromEnriched( - enriched: FroggyEnrichedView -): FroggyTrendPullbackScore { - const input = buildFroggyTrendPullbackInputFromEnriched(enriched); - return scoreFroggyTrendPullback(input, defaultUwrConfig, enriched); -}