From 8548879e7c3c3161da2e0b391239bb451f989cff Mon Sep 17 00:00:00 2001 From: Giovanni Giovanni Date: Tue, 25 Aug 2026 17:29:08 -0700 Subject: [PATCH 1/3] 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); -} From df746d3c53e35ad3954b064ebd05ab66aa552bb4 Mon Sep 17 00:00:00 2001 From: Giovanni Giovanni Date: Tue, 25 Aug 2026 19:30:02 -0700 Subject: [PATCH 2/3] DEM-PRODUCER-CANDLE (core): delete the stub and the haFlatBackConfirmed literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEM-GOV §9 DEM-PRODUCER-CANDLE (owner-authorized 2026-08-25; afi-governance #55) — the act D5-GOV D-D5-1 reserved. Merge after dem/plan-core, and after the reactor's PLAN wave has merged. - froggy.enrichment_adapter.ts: BROKE_EMA_WITH_BODY_UNIMPLEMENTED_STUB and its ?? read are DELETED, as is the `haFlatBackConfirmed: false` literal. Both are now computed technical-lane facts the registered mapping binds (required). FroggyAdapterOutput drops them; the view type gains the lane's brokeEmaWithBody / haFlatBack / haFlatBackConfirmed facts. - froggy.residual_builder.ts: the residual Pick shrinks to the HTF bias placeholders + liquiditySwept — three fields. - tests: mapping 1.2.0 in the shared support module (required binds); probes carry the candle facts and vary them; a view WITHOUT them now REFUSES at the interpreter (required-source-absent) instead of taking a stub — pinned in both the residual-builder and adapter suites. afi-core 313/313. No scoring-law value moves. Co-Authored-By: Claude Fable 5 --- .../froggy.enrichment_adapter.test.ts | 24 +++--- .../__tests__/froggy.residual_builder.test.ts | 77 +++++++++++-------- .../froggy.trend_pullback_v1.test.ts | 2 + analysts/__tests__/support/froggyMappings.ts | 37 ++++++++- analysts/froggy.enrichment_adapter.ts | 45 ++++++----- analysts/froggy.residual_builder.ts | 18 ++--- 6 files changed, 122 insertions(+), 81 deletions(-) diff --git a/analysts/__tests__/froggy.enrichment_adapter.test.ts b/analysts/__tests__/froggy.enrichment_adapter.test.ts index 7fa9178..e46823a 100644 --- a/analysts/__tests__/froggy.enrichment_adapter.test.ts +++ b/analysts/__tests__/froggy.enrichment_adapter.test.ts @@ -5,7 +5,6 @@ import { } from "../froggy.trend_pullback_v1"; import { defaultUwrConfig } from "../../validators/UniversalWeightingRule"; import { - BROKE_EMA_WITH_BODY_UNIMPLEMENTED_STUB, buildFroggyTrendPullbackInputFromEnriched, type FroggyEnrichedView } from "../froggy.enrichment_adapter"; @@ -35,7 +34,10 @@ describe("froggy.enrichment_adapter", () => { technical: { emaDistancePct: 0.5, isInValueSweetSpot: true, + // 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). @@ -61,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", @@ -69,15 +71,7 @@ describe("froggy.enrichment_adapter", () => { timeframe: "4h" // no technical/pattern/sentiment/news/aiMl }; - - const result = scoreViaMapping(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 @@ -182,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 97fb2e0..6843396 100644 --- a/analysts/__tests__/froggy.residual_builder.test.ts +++ b/analysts/__tests__/froggy.residual_builder.test.ts @@ -24,12 +24,16 @@ import { } from "../froggy.enrichment_adapter.js"; import { interpretEnrichmentMapping } from "../../validators/EnrichmentMappingInterpreter.js"; import { - froggyMapping110, + froggyMapping120, loadRegisteredFroggyMapping, NEWEST_REGISTERED_FROGGY_MAPPING_VERSION, } from "./support/froggyMappings.js"; -const REGISTERED = loadRegisteredFroggyMapping(NEWEST_REGISTERED_FROGGY_MAPPING_VERSION, froggyMapping110); +const REGISTERED = loadRegisteredFroggyMapping(NEWEST_REGISTERED_FROGGY_MAPPING_VERSION, froggyMapping120); + +/** 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 }; function view(overrides: Partial): FroggyEnrichedView { return { @@ -37,7 +41,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 }, pattern: { patternName: "bull flag", patternConfidence: 80 }, sentiment: { score: 0.4, tags: ["liquidity sweep"] }, ...overrides, @@ -55,23 +59,23 @@ const PROBES: Array<[string, FroggyEnrichedView]> = [ ["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 } })], + ["recode extreme", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "extreme", ...CANDLE } })], + ["recode present-normal", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "normal", ...CANDLE } })], + ["recode unrecognized", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "weird", ...CANDLE } })], + ["recode absent", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, ...CANDLE } })], + ["grandfathers fire (technical carries only the candle facts)", view({ technical: { ...CANDLE } })], + ["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 } } 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" } })], - ["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 } })], + ["negative distance out of sweet spot", view({ technical: { emaDistancePct: -3.2, isInValueSweetSpot: false, atrRegime: "low", ...CANDLE } })], + ["plan present: rr 1.4286", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", ...CANDLE, 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, 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, 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 } })], + ["candle facts: flat-back present but unconfirmed", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", brokeEmaWithBody: false, haFlatBack: "bearish", haFlatBackConfirmed: false } })], + ["plan block null", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", ...CANDLE, plan: null } })], ]; /** What the registered mapping must yield for the PLAN-produced field. */ @@ -83,7 +87,7 @@ function expectedRr(v: FroggyEnrichedView): number { 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()); + expect(REGISTERED.doc).toStrictEqual(froggyMapping120()); }); for (const [label, v] of PROBES) { @@ -92,25 +96,34 @@ describe(`compose(fragment(${NEWEST_REGISTERED_FROGGY_MAPPING_VERSION}), residua const residual = buildFroggyResidualInput(v); const composed = composeFroggyTrendPullbackInput(result.fragment, residual); 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"); + 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, + }); + // The producer facts ride the mapping, never the adapter (synthesis, + // stub, and literal deleted). + for (const gone of ["rrMultiplePlanned", "brokeEmaWithBody", "haFlatBackConfirmed"]) { + 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 still-unexpressible fields (CANDLE + HTF placeholders + liquiditySwept)", () => { + it("the residual carries exactly the still-unexpressible fields (HTF placeholders + liquiditySwept)", () => { const residual = buildFroggyResidualInput(view({})); - expect(Object.keys(residual).sort()).toEqual([ - "brokeEmaWithBody", - "dailyBias", - "haFlatBackConfirmed", - "liquiditySwept", - "weeklyBias", - ]); + expect(Object.keys(residual).sort()).toEqual(["dailyBias", "liquiditySwept", "weeklyBias"]); + }); + + 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", () => { @@ -137,6 +150,8 @@ describe("the composer fails closed (D-DEM-5(2))", () => { triggerPatternQuality: 2, atrRegime: "low", rrMultiplePlanned: 2, + brokeEmaWithBody: false, + haFlatBackConfirmed: true, }; it("composes a complete partition", () => { diff --git a/analysts/__tests__/froggy.trend_pullback_v1.test.ts b/analysts/__tests__/froggy.trend_pullback_v1.test.ts index fa844de..9ca6809 100644 --- a/analysts/__tests__/froggy.trend_pullback_v1.test.ts +++ b/analysts/__tests__/froggy.trend_pullback_v1.test.ts @@ -108,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", diff --git a/analysts/__tests__/support/froggyMappings.ts b/analysts/__tests__/support/froggyMappings.ts index e43c616..a35878d 100644 --- a/analysts/__tests__/support/froggyMappings.ts +++ b/analysts/__tests__/support/froggyMappings.ts @@ -95,9 +95,42 @@ export function froggyMapping110(): Record { }; } +/** 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", + }, + }, + }; +} + /** 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"; +export const NEWEST_REGISTERED_FROGGY_MAPPING = froggyMapping120; +export const NEWEST_REGISTERED_FROGGY_MAPPING_VERSION = "1.2.0"; /** Prefer the sibling registry file when present (and let the suite drift-check it). */ export function loadRegisteredFroggyMapping(version: string, inline: () => Record): { diff --git a/analysts/froggy.enrichment_adapter.ts b/analysts/froggy.enrichment_adapter.ts index be5cd65..31333b5 100644 --- a/analysts/froggy.enrichment_adapter.ts +++ b/analysts/froggy.enrichment_adapter.ts @@ -19,10 +19,12 @@ // 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. +// D-DEM-5(6)) and the registered mapping binds. DEM-PRODUCER-CANDLE deleted +// BROKE_EMA_WITH_BODY_UNIMPLEMENTED_STUB and the `haFlatBackConfirmed: false` +// literal: both are computed lane facts the mapping binds (required). This +// builder's output is FroggyAdapterOutput, not a full scorer input. The HTF +// bias placeholders leave with DEM-PRODUCER-HTF and the residue with +// DEM-PLACEHOLDER-GUARD. import type { FroggyTrendPullbackInput } from "./froggy.trend_pullback_v1.js"; @@ -59,16 +61,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; @@ -78,7 +70,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 @@ -262,10 +263,14 @@ const quantisePatternConfidence = (confidence: number): 0 | 1 | 2 | 3 => { */ /** * 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). + * 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; +export type FroggyAdapterOutput = Omit< + FroggyTrendPullbackInput, + "rrMultiplePlanned" | "brokeEmaWithBody" | "haFlatBackConfirmed" +>; export function buildFroggyTrendPullbackInputFromEnriched( enriched: FroggyEnrichedView @@ -276,10 +281,6 @@ export function buildFroggyTrendPullbackInputFromEnriched( 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 @@ -313,10 +314,8 @@ export function buildFroggyTrendPullbackInputFromEnriched( return { weeklyBias, dailyBias, - haFlatBackConfirmed: false, // TODO: map from enriched technical context when available distanceFromDailyEmaPct, pulledBackIntoSweetSpot, - brokeEmaWithBody, liquiditySwept, triggerPatternQuality, atrRegime, diff --git a/analysts/froggy.residual_builder.ts b/analysts/froggy.residual_builder.ts index bb22c94..9a8a3dd 100644 --- a/analysts/froggy.residual_builder.ts +++ b/analysts/froggy.residual_builder.ts @@ -9,11 +9,11 @@ * 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. + * the HTF bias placeholders awaiting DEM-PRODUCER-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 and brokeEmaWithBody + haFlatBackConfirmed with + * DEM-PRODUCER-CANDLE: computed lane facts the mapping binds. * * On the live mapping path the adapter's expressible computation still * executes here and its expressible outputs are DISCARDED — only the @@ -37,11 +37,11 @@ import { buildFroggyTrendPullbackInputFromEnriched } from "./froggy.enrichment_a import type { FroggyTrendPullbackInput } from "./froggy.trend_pullback_v1.js"; import type { EnrichmentMappingResult } from "../validators/EnrichmentMappingInterpreter.js"; -/** The fields the mapping cannot express yet (D-DEM-3): the placeholders - * awaiting DEM-PRODUCER-CANDLE / DEM-PRODUCER-HTF + the reserved liquiditySwept. */ +/** The fields the mapping cannot express yet (D-DEM-3): the HTF bias + * placeholders awaiting DEM-PRODUCER-HTF + the reserved liquiditySwept. */ export type FroggyResidualInput = Pick< FroggyTrendPullbackInput, - "weeklyBias" | "dailyBias" | "haFlatBackConfirmed" | "brokeEmaWithBody" | "liquiditySwept" + "weeklyBias" | "dailyBias" | "liquiditySwept" >; type FroggyInputField = keyof FroggyTrendPullbackInput; @@ -105,8 +105,6 @@ export function buildFroggyResidualInput( return { weeklyBias: full.weeklyBias, dailyBias: full.dailyBias, - haFlatBackConfirmed: full.haFlatBackConfirmed, - brokeEmaWithBody: full.brokeEmaWithBody, liquiditySwept: full.liquiditySwept, }; } From 836dd33843b2b18f5e6c27e8116a2602cedfbe88 Mon Sep 17 00:00:00 2001 From: Giovanni Giovanni Date: Tue, 25 Aug 2026 19:37:39 -0700 Subject: [PATCH 3/3] DEM-PRODUCER-HTF (core): delete the neutral bias literals; resolve the "unknown" branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEM-GOV §9 DEM-PRODUCER-HTF (owner-authorized 2026-08-25; afi-governance #55) — the mission DIR-GOV D-DIR-3's scope-guard reserved. Merge after the CANDLE wave. - froggy.enrichment_adapter.ts: the `weeklyBias = "neutral" as const` and `dailyBias = "neutral" as const` literals are DELETED. Both are now computed technical-lane facts (higher-timeframe trend) that the registered mapping RECODES into the rubric's bias vocabulary. The view type gains technical.htf {daily,weekly}. NOTHING this builder returns is a placeholder any more. - froggy.residual_builder.ts: the residual is now ONE field, liquiditySwept — the two-lane read D-DEM-3(5) expressly reserves. The D-DEM-4(2) placeholder inventory is EMPTY. - froggy.trend_pullback_v1.ts: the "unknown"-direction branch semantics are RESOLVED as DIR-GOV's scope-guard requires (they could not be settled while both biases were hardcoded and the branch unreachable): with real HTF facts the four cases are exhaustive — long/short when both timeframes agree, neutral when either has no directional bias, and "unknown" when both are directional and DISAGREE (a higher-timeframe CONFLICT: the analyst declining to call a direction, not an error and not an absence). It feeds no axis and never reaches scoredSignal.direction. No code changed — the branch is documented and pinned. - tests: mapping 1.3.0 in the shared support (the bullish->long / bearish->short / range->neutral vocabulary translation lives in the MAPPING, never in the producer, D-DEM-3); probes for aligned / conflicting / range / absent-window HTF; five new direction-verdict tests including the conflict branch and a proof that the verdict moves no axis. afi-core 322/322. No scoring-law value moves. Co-Authored-By: Claude Fable 5 --- .../__tests__/froggy.residual_builder.test.ts | 75 ++++++++++++------- .../froggy.trend_pullback_v1.test.ts | 41 ++++++++++ analysts/__tests__/support/froggyMappings.ts | 36 ++++++++- analysts/froggy.enrichment_adapter.ts | 31 +++++--- analysts/froggy.residual_builder.ts | 28 +++---- analysts/froggy.trend_pullback_v1.ts | 21 +++++- 6 files changed, 176 insertions(+), 56 deletions(-) diff --git a/analysts/__tests__/froggy.residual_builder.test.ts b/analysts/__tests__/froggy.residual_builder.test.ts index 6843396..413728c 100644 --- a/analysts/__tests__/froggy.residual_builder.test.ts +++ b/analysts/__tests__/froggy.residual_builder.test.ts @@ -24,16 +24,24 @@ import { } from "../froggy.enrichment_adapter.js"; import { interpretEnrichmentMapping } from "../../validators/EnrichmentMappingInterpreter.js"; import { - froggyMapping120, + froggyMapping130, loadRegisteredFroggyMapping, NEWEST_REGISTERED_FROGGY_MAPPING_VERSION, } from "./support/froggyMappings.js"; -const REGISTERED = loadRegisteredFroggyMapping(NEWEST_REGISTERED_FROGGY_MAPPING_VERSION, froggyMapping120); +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 { @@ -41,7 +49,7 @@ function view(overrides: Partial): FroggyEnrichedView { symbol: "BTCUSDT", market: "crypto", timeframe: "4h", - technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", ...CANDLE }, + technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", ...CANDLE, ...HTF }, pattern: { patternName: "bull flag", patternConfidence: 80 }, sentiment: { score: 0.4, tags: ["liquidity sweep"] }, ...overrides, @@ -59,23 +67,26 @@ const PROBES: Array<[string, FroggyEnrichedView]> = [ ["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", ...CANDLE } })], - ["recode extreme", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "extreme", ...CANDLE } })], - ["recode present-normal", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "normal", ...CANDLE } })], - ["recode unrecognized", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "weird", ...CANDLE } })], - ["recode absent", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, ...CANDLE } })], - ["grandfathers fire (technical carries only the candle facts)", view({ technical: { ...CANDLE } })], + ["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 } } as unknown as Partial)], + ["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", ...CANDLE } })], - ["plan present: rr 1.4286", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", ...CANDLE, 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, 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, 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 } })], - ["candle facts: flat-back present but unconfirmed", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", brokeEmaWithBody: false, haFlatBack: "bearish", haFlatBackConfirmed: false } })], - ["plan block null", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low", ...CANDLE, plan: null } })], + ["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 } })], ]; /** What the registered mapping must yield for the PLAN-produced field. */ @@ -87,7 +98,7 @@ function expectedRr(v: FroggyEnrichedView): number { 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(froggyMapping120()); + expect(REGISTERED.doc).toStrictEqual(froggyMapping130()); }); for (const [label, v] of PROBES) { @@ -96,16 +107,20 @@ describe(`compose(fragment(${NEWEST_REGISTERED_FROGGY_MAPPING_VERSION}), residua const residual = buildFroggyResidualInput(v); const composed = composeFroggyTrendPullbackInput(result.fragment, residual); 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), }); - // The producer facts ride the mapping, never the adapter (synthesis, - // stub, and literal deleted). - for (const gone of ["rrMultiplePlanned", "brokeEmaWithBody", "haFlatBackConfirmed"]) { + // 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. @@ -114,9 +129,9 @@ describe(`compose(fragment(${NEWEST_REGISTERED_FROGGY_MAPPING_VERSION}), residua }); } - it("the residual carries exactly the still-unexpressible fields (HTF placeholders + liquiditySwept)", () => { + 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(["dailyBias", "liquiditySwept", "weeklyBias"]); + 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", () => { @@ -152,6 +167,8 @@ describe("the composer fails closed (D-DEM-5(2))", () => { rrMultiplePlanned: 2, brokeEmaWithBody: false, haFlatBackConfirmed: true, + weeklyBias: "long", + dailyBias: "long", }; it("composes a complete partition", () => { @@ -173,8 +190,8 @@ describe("the composer fails closed (D-DEM-5(2))", () => { it("refuses a fragment that also supplies a residual field (no dual source)", () => { expect(() => - composeFroggyTrendPullbackInput({ ...goodFragment, weeklyBias: "long" }, residual) - ).toThrow(/both supply \[weeklyBias\]/); + composeFroggyTrendPullbackInput({ ...goodFragment, liquiditySwept: false }, residual) + ).toThrow(/both supply \[liquiditySwept\]/); }); it("refuses wrong-typed targets, including an out-of-grade integer and a non-finite number", () => { @@ -194,7 +211,13 @@ describe("the composer fails closed (D-DEM-5(2))", () => { it("refuses a residual value outside its domain (the residual is checked too)", () => { expect(() => - composeFroggyTrendPullbackInput(goodFragment, { ...residual, weeklyBias: "sideways" as never }) + 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 9ca6809..bd7244c 100644 --- a/analysts/__tests__/froggy.trend_pullback_v1.test.ts +++ b/analysts/__tests__/froggy.trend_pullback_v1.test.ts @@ -130,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 index a35878d..f61ba02 100644 --- a/analysts/__tests__/support/froggyMappings.ts +++ b/analysts/__tests__/support/froggyMappings.ts @@ -128,9 +128,41 @@ export function froggyMapping120(): Record { }; } +/** 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 = froggyMapping120; -export const NEWEST_REGISTERED_FROGGY_MAPPING_VERSION = "1.2.0"; +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): { diff --git a/analysts/froggy.enrichment_adapter.ts b/analysts/froggy.enrichment_adapter.ts index 31333b5..e2f244b 100644 --- a/analysts/froggy.enrichment_adapter.ts +++ b/analysts/froggy.enrichment_adapter.ts @@ -21,10 +21,11 @@ // 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: both are computed lane facts the mapping binds (required). This -// builder's output is FroggyAdapterOutput, not a full scorer input. The HTF -// bias placeholders leave with DEM-PRODUCER-HTF and the residue with -// DEM-PLACEHOLDER-GUARD. +// 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"; @@ -130,6 +131,20 @@ export interface FroggyEnrichedView { * 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; @@ -269,7 +284,7 @@ const quantisePatternConfidence = (confidence: number): 0 | 1 | 2 | 3 => { */ export type FroggyAdapterOutput = Omit< FroggyTrendPullbackInput, - "rrMultiplePlanned" | "brokeEmaWithBody" | "haFlatBackConfirmed" + "rrMultiplePlanned" | "brokeEmaWithBody" | "haFlatBackConfirmed" | "weeklyBias" | "dailyBias" >; export function buildFroggyTrendPullbackInputFromEnriched( @@ -307,13 +322,7 @@ 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; - return { - weeklyBias, - dailyBias, distanceFromDailyEmaPct, pulledBackIntoSweetSpot, liquiditySwept, diff --git a/analysts/froggy.residual_builder.ts b/analysts/froggy.residual_builder.ts index 9a8a3dd..d830920 100644 --- a/analysts/froggy.residual_builder.ts +++ b/analysts/froggy.residual_builder.ts @@ -9,11 +9,12 @@ * 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 HTF bias placeholders awaiting DEM-PRODUCER-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 and brokeEmaWithBody + haFlatBackConfirmed with - * DEM-PRODUCER-CANDLE: computed lane facts the mapping binds. + * `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 @@ -37,12 +38,11 @@ import { buildFroggyTrendPullbackInputFromEnriched } from "./froggy.enrichment_a import type { FroggyTrendPullbackInput } from "./froggy.trend_pullback_v1.js"; import type { EnrichmentMappingResult } from "../validators/EnrichmentMappingInterpreter.js"; -/** The fields the mapping cannot express yet (D-DEM-3): the HTF bias - * placeholders awaiting DEM-PRODUCER-HTF + the reserved liquiditySwept. */ -export type FroggyResidualInput = Pick< - FroggyTrendPullbackInput, - "weeklyBias" | "dailyBias" | "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; type FroggyInputField = keyof FroggyTrendPullbackInput; @@ -102,11 +102,7 @@ export function buildFroggyResidualInput( enriched: FroggyEnrichedView ): FroggyResidualInput { const full = buildFroggyTrendPullbackInputFromEnriched(enriched); - return { - weeklyBias: full.weeklyBias, - dailyBias: full.dailyBias, - liquiditySwept: full.liquiditySwept, - }; + return { liquiditySwept: full.liquiditySwept }; } function refuse(detail: string): never { diff --git a/analysts/froggy.trend_pullback_v1.ts b/analysts/froggy.trend_pullback_v1.ts index 384af00..dafda27 100644 --- a/analysts/froggy.trend_pullback_v1.ts +++ b/analysts/froggy.trend_pullback_v1.ts @@ -186,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" :