From 8548879e7c3c3161da2e0b391239bb451f989cff Mon Sep 17 00:00:00 2001 From: Giovanni Giovanni Date: Tue, 25 Aug 2026 17:29:08 -0700 Subject: [PATCH] DEM-PRODUCER-PLAN (core): delete the rrMultiplePlanned synthesis; generalize the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEM-GOV §9 DEM-PRODUCER-PLAN (owner-authorized 2026-08-25; afi-governance #55): the planned R:R is a PROVIDER fact the technical lane produces from the submitted afi.trade-plan.v1 and the registered mapping binds — never synthesized from analyst inputs again. - froggy.enrichment_adapter.ts: the synthesis at the old :282-283 is deleted; the builder returns FroggyAdapterOutput (Omit<…,'rrMultiplePlanned'>); the view type gains technical.plan (the lane's verified plan facts, read only through the mapping). - froggy.residual_builder.ts: residual Pick shrinks to the five still- unexpressible fields; the composer is mapping-version-agnostic and fail-closed — fragment ∪ residual must partition the ten scorer-input fields exactly and every value must lie in its declared domain (predicate-valued table: no scorer-input field is ever assigned a literal here). - froggy.trend_pullback_v1.ts: scoreFroggyTrendPullbackFromEnriched (the adapter-only convenience path) is deleted — the adapter no longer emits a full input; the rubric no longer imports the adapter. - tests: the registered 1.1.0 mapping as shared test data (inline, drift- checked against the sibling registry file when present); the residual builder suite proves compose(fragment, residual) == adapter output + the mapping-bound R:R (fired default recorded when the producer emitted none); composer refusal cases (missing / extra / dual-source / out-of-domain). No scoring-law value moves. afi-core suite 312/312. Co-Authored-By: Claude Fable 5 --- .../froggy.enrichment_adapter.test.ts | 28 ++- .../__tests__/froggy.residual_builder.test.ts | 186 +++++++++-------- .../froggy.trend_pullback_v1.test.ts | 21 +- analysts/__tests__/support/froggyMappings.ts | 112 +++++++++++ analysts/froggy.enrichment_adapter.ts | 43 +++- analysts/froggy.residual_builder.ts | 189 +++++++++++------- analysts/froggy.trend_pullback_v1.ts | 8 - 7 files changed, 401 insertions(+), 186 deletions(-) create mode 100644 analysts/__tests__/support/froggyMappings.ts diff --git a/analysts/__tests__/froggy.enrichment_adapter.test.ts b/analysts/__tests__/froggy.enrichment_adapter.test.ts index 87884b0..7fa9178 100644 --- a/analysts/__tests__/froggy.enrichment_adapter.test.ts +++ b/analysts/__tests__/froggy.enrichment_adapter.test.ts @@ -1,13 +1,29 @@ 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 +35,11 @@ describe("froggy.enrichment_adapter", () => { technical: { emaDistancePct: 0.5, isInValueSweetSpot: true, - brokeEmaWithBody: false + brokeEmaWithBody: false, + // 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 +51,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); @@ -50,7 +70,7 @@ describe("froggy.enrichment_adapter", () => { // no technical/pattern/sentiment/news/aiMl }; - const result = scoreFroggyTrendPullbackFromEnriched(enriched); + const result = scoreViaMapping(enriched); // All scoring data is now in analystScore (canonical) expect(result.analystScore.uwrAxes.structure).toBeDefined(); diff --git a/analysts/__tests__/froggy.residual_builder.test.ts b/analysts/__tests__/froggy.residual_builder.test.ts index 04ec4ca..97fb2e0 100644 --- a/analysts/__tests__/froggy.residual_builder.test.ts +++ b/analysts/__tests__/froggy.residual_builder.test.ts @@ -1,76 +1,35 @@ /** - * 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 { + froggyMapping110, + 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, froggyMapping110); function view(overrides: Partial): FroggyEnrichedView { return { @@ -86,9 +45,10 @@ 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 } })], @@ -108,66 +68,118 @@ const PROBES: Array<[string, FroggyEnrichedView]> = [ ["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" } })], + ["plan present: rr 1.4286", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", 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", 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", plan: { entryPrice: 3001.5, targetCount: 0 } } })], + ["plan block null", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", 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(froggyMapping110()); + }); + 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); + expect(composed).toStrictEqual({ ...adapter, rrMultiplePlanned: expectedRr(v) }); + // The PLAN fact rides the mapping, never the adapter: the adapter's + // output carries no rrMultiplePlanned at all (synthesis deleted). + expect(Object.keys(adapter)).not.toContain("rrMultiplePlanned"); + // 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 exactly the still-unexpressible fields (CANDLE + HTF placeholders + liquiditySwept)", () => { const residual = buildFroggyResidualInput(view({})); expect(Object.keys(residual).sort()).toEqual([ "brokeEmaWithBody", "dailyBias", "haFlatBackConfirmed", "liquiditySwept", + "weeklyBias", + ]); + }); + + 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, + }; - it("refuses a fragment missing a declared target", () => { + 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 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, weeklyBias: "long" }, residual) + ).toThrow(/both supply \[weeklyBias\]/); }); - 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, { ...residual, weeklyBias: "sideways" as never }) + ).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..fa844de 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", @@ -98,7 +115,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"); diff --git a/analysts/__tests__/support/froggyMappings.ts b/analysts/__tests__/support/froggyMappings.ts new file mode 100644 index 0000000..e43c616 --- /dev/null +++ b/analysts/__tests__/support/froggyMappings.ts @@ -0,0 +1,112 @@ +/** + * 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" }, + }, + }, + }, + }; +} + +/** The newest registered mapping this afi-core revision composes against. */ +export const NEWEST_REGISTERED_FROGGY_MAPPING = froggyMapping110; +export const NEWEST_REGISTERED_FROGGY_MAPPING_VERSION = "1.1.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..be5cd65 100644 --- a/analysts/froggy.enrichment_adapter.ts +++ b/analysts/froggy.enrichment_adapter.ts @@ -14,10 +14,15 @@ // 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; this builder no longer emits +// it, so its output is FroggyAdapterOutput, not a full scorer input. The +// remaining placeholders leave with their producer slots (CANDLE, HTF) and +// the residue with DEM-PLACEHOLDER-GUARD. import type { FroggyTrendPullbackInput } from "./froggy.trend_pullback_v1.js"; @@ -115,6 +120,25 @@ 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. + */ + 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,9 +260,16 @@ 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` is a + * mapping-bound provider fact and is never synthesized here again). + */ +export type FroggyAdapterOutput = Omit; + export function buildFroggyTrendPullbackInputFromEnriched( enriched: FroggyEnrichedView -): FroggyTrendPullbackInput { +): FroggyAdapterOutput { const technical = enriched.technical ?? {}; const pattern = enriched.pattern ?? {}; const sentiment = enriched.sentiment ?? {}; @@ -279,9 +310,6 @@ export function buildFroggyTrendPullbackInputFromEnriched( const weeklyBias = "neutral" as const; const dailyBias = "neutral" as const; - const rrMultiplePlanned = - pulledBackIntoSweetSpot && !brokeEmaWithBody ? 2 : 1; - return { weeklyBias, dailyBias, @@ -292,6 +320,5 @@ export function buildFroggyTrendPullbackInputFromEnriched( 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..bb22c94 100644 --- a/analysts/froggy.residual_builder.ts +++ b/analysts/froggy.residual_builder.ts @@ -2,17 +2,18 @@ * 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): + * the placeholders still awaiting their producer slots (CANDLE: brokeEmaWithBody + * + haFlatBackConfirmed; HTF: weeklyBias + dailyBias) plus liquiditySwept (a + * two-lane read, inexpressible by construction — D-DEM-3(5), reserved). + * rrMultiplePlanned left the residual with DEM-PRODUCER-PLAN: it is a + * provider fact the technical lane produces and the 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 +22,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,31 +37,65 @@ 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). */ +/** The fields the mapping cannot express yet (D-DEM-3): the placeholders + * awaiting DEM-PRODUCER-CANDLE / DEM-PRODUCER-HTF + the reserved liquiditySwept. */ export type FroggyResidualInput = Pick< FroggyTrendPullbackInput, - | "weeklyBias" - | "dailyBias" - | "haFlatBackConfirmed" - | "brokeEmaWithBody" - | "rrMultiplePlanned" - | "liquiditySwept" + "weeklyBias" | "dailyBias" | "haFlatBackConfirmed" | "brokeEmaWithBody" | "liquiditySwept" >; -/** 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 ExpressibleTarget = (typeof EXPRESSIBLE_TARGETS)[number]; +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"; +} + +/** 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( @@ -70,67 +107,65 @@ export function buildFroggyResidualInput( dailyBias: full.dailyBias, haFlatBackConfirmed: full.haFlatBackConfirmed, brokeEmaWithBody: full.brokeEmaWithBody, - rrMultiplePlanned: full.rrMultiplePlanned, 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..384af00 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 @@ -308,10 +307,3 @@ export function scoreFroggyTrendPullback( notes: notes.length ? notes : undefined, }; } - -export function scoreFroggyTrendPullbackFromEnriched( - enriched: FroggyEnrichedView -): FroggyTrendPullbackScore { - const input = buildFroggyTrendPullbackInputFromEnriched(enriched); - return scoreFroggyTrendPullback(input, defaultUwrConfig, enriched); -}