From 8548879e7c3c3161da2e0b391239bb451f989cff Mon Sep 17 00:00:00 2001 From: Giovanni Giovanni Date: Tue, 25 Aug 2026 17:29:08 -0700 Subject: [PATCH 1/4] 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/4] 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/4] 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" : From 65775a3e2b570f132fc364e59b5b602d612c4041 Mon Sep 17 00:00:00 2001 From: Giovanni Giovanni Date: Tue, 25 Aug 2026 19:56:58 -0700 Subject: [PATCH 4/4] =?UTF-8?q?DEM-PLACEHOLDER-GUARD=20(core):=20the=20ter?= =?UTF-8?q?minal=20guard=20=E2=80=94=20a=20placeholder=20can=20never=20com?= =?UTF-8?q?e=20back?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEM-GOV §9 DEM-PLACEHOLDER-GUARD (owner-authorized 2026-08-25; afi-governance #55), enforcing D-DEM-4(5). Merge last, after all three producer waves. - validators/PlaceholderLiteralGuard.ts (NEW): reads the field set from the FroggyTrendPullbackInput interface itself (so it can never drift from the rubric's contract) and flags any assignment to one of those field names whose value is a compile-time constant. Constancy is decided by the TYPE CHECKER as well as the syntax tree, so the exact retired form — a constant imported ACROSS a package boundary, whose initializer lives in another repo's .d.ts — is caught; a syntax-only guard cannot see it. Forms covered: literals, `as const`, unary of a literal, substitution-free templates, `x ?? CONST`, conditionals between constants, variable declarations, shorthand properties, and assignment expressions. The `typescript` API is imported LAZILY, so the published module is inert unless a test invokes it. - Exemptions are exactly the accepted text's: `liquiditySwept` (the reserved two-lane computation, D-DEM-3(5)); test directories; and a registered mapping's declared `default` literals — pinned to the REGISTERED VALUES, so the two D-DEM-5(4) grandfathers the retired adapter still applies (its physical deletion is reserved by DEM-BIND ruling R1) cannot drift from the document, and ANY other literal at the same site still fires. A synthesis is caught even when one arm equals a registered default. - test/guardrails/placeholderLiteralGuard.test.ts (NEW, 17 tests): the tree is CLEAN; six negative tests prove the guard bites on every retired form, including the cross-package one; five exemption tests prove it does not fire on mapping-sourced values, a mapping document's own literals, liquiditySwept, test fixtures, or the composer's predicate table. Zero scored-value movement by this slot. afi-core 339/339. Co-Authored-By: Claude Fable 5 --- .../placeholderLiteralGuard.test.ts | 192 +++++++++++ validators/PlaceholderLiteralGuard.ts | 320 ++++++++++++++++++ 2 files changed, 512 insertions(+) create mode 100644 test/guardrails/placeholderLiteralGuard.test.ts create mode 100644 validators/PlaceholderLiteralGuard.ts diff --git a/test/guardrails/placeholderLiteralGuard.test.ts b/test/guardrails/placeholderLiteralGuard.test.ts new file mode 100644 index 0000000..707c512 --- /dev/null +++ b/test/guardrails/placeholderLiteralGuard.test.ts @@ -0,0 +1,192 @@ +/** + * DEM-PLACEHOLDER-GUARD — the terminal CI guard over afi-core/analysts/** + * (D-DEM-4(5); DEM-GOV §9, owner-authorized 2026-08-25). + * + * Gate: "Guard proven to fail on a reintroduced literal by a negative test, + * and proven NOT to fire on a registered mapping's declared literals or on + * liquiditySwept; the D-DEM-4(2) inventory empty." + */ +import { describe, it, expect } from "vitest"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { readFileSync } from "node:fs"; +import { scanForPlaceholderLiterals } from "../../validators/PlaceholderLiteralGuard.js"; +import { NEWEST_REGISTERED_FROGGY_MAPPING } from "../../analysts/__tests__/support/froggyMappings.js"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const TSCONFIG = path.join(REPO_ROOT, "tsconfig.json"); +const BOUNDS = ["analysts"]; // D-DEM-4(5)'s afi-core bound + +/** + * D-DEM-4(5)'s mapping-sourced exemption, read from the REGISTERED mapping + * itself: only a value equal to a declared `default` for that same target is + * exempt. Today that is the two D-DEM-5(4) grandfathers the retired adapter + * still applies (its physical deletion is reserved by DEM-BIND ruling R1). + */ +function registeredDefaults(): Record { + const doc = NEWEST_REGISTERED_FROGGY_MAPPING() as { + bindings: Record; + }; + const out: Record = {}; + for (const [target, binding] of Object.entries(doc.bindings)) { + if (binding.optionality?.default !== undefined) out[target] = binding.optionality.default; + } + return out; +} + +const scan = (extraSources?: Array<{ fileName: string; content: string }>) => + scanForPlaceholderLiterals({ + tsconfigPath: TSCONFIG, + bounds: BOUNDS, + repoRoot: REPO_ROOT, + mappingDeclaredDefaults: registeredDefaults(), + extraSources, + }); + +/** A fixture inside the bound, compiled against the real declarations. */ +const fixture = (body: string) => [ + { + fileName: path.join(REPO_ROOT, "analysts", "__guard_fixture__.ts"), + content: `import type { FroggyTrendPullbackInput } from "./froggy.trend_pullback_v1.js";\n${body}\n`, + }, +]; + +describe("DEM-PLACEHOLDER-GUARD: the production path carries no placeholder literal", () => { + it("afi-core/analysts/** is CLEAN — the D-DEM-4(2) inventory is empty", async () => { + expect(await scan()).toEqual([]); + }, 60_000); + + it("the exemption is exactly the REGISTERED document's declared defaults — nothing wider", () => { + // D-DEM-4(5) exempts "a registered mapping document's declared `default` + // literals". Read from the document itself, that is: the two D-DEM-5(4) + // grandfathers (which the retired adapter still applies — its physical + // deletion is reserved by DEM-BIND ruling R1) and the PLAN slot's + // producer-declared floor. The exemption is pinned to their VALUES, so the + // adapter's dormant copy cannot drift from the registered document. + expect(registeredDefaults()).toEqual({ + distanceFromDailyEmaPct: 0, + pulledBackIntoSweetSpot: false, + rrMultiplePlanned: 1, + }); + }); + + it("a SYNTHESIS is still caught even when one of its arms is a registered default", async () => { + // The retired `pulledBackIntoSweetSpot && !brokeEmaWithBody ? 2 : 1` form: + // a conditional has no single constant value, so the exemption cannot + // apply and the guard fires. + const findings = await scan( + fixture(`declare const a: boolean; export const s = { rrMultiplePlanned: a ? 2 : 1 };`) + ); + expect(findings.map((f) => f.field)).toContain("rrMultiplePlanned"); + }, 60_000); + + it("a DIFFERENT literal at a grandfathered site still fires (the exemption cannot be abused)", async () => { + const findings = await scan( + fixture(`declare const t: { emaDistancePct?: number }; export const g = { distanceFromDailyEmaPct: t.emaDistancePct ?? 1 };`) + ); + expect(findings.map((f) => f.field)).toContain("distanceFromDailyEmaPct"); + }, 60_000); + + it("the retired inventory's identifiers and literals are gone from the CODE", () => { + // Comments naming the retired constants are documentation, not code. + const raw = readFileSync(path.join(REPO_ROOT, "analysts/froggy.enrichment_adapter.ts"), "utf-8"); + const adapter = raw.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " "); + expect(adapter).not.toContain("BROKE_EMA_WITH_BODY_UNIMPLEMENTED_STUB"); + expect(adapter).not.toMatch(/haFlatBackConfirmed:\s*false/); + expect(adapter).not.toMatch(/weeklyBias\s*=\s*"neutral"/); + expect(adapter).not.toMatch(/dailyBias\s*=\s*"neutral"/); + expect(adapter).not.toMatch(/rrMultiplePlanned\s*=/); + // No compatibility shim, alias, dual-run flag or commented-out copy + // survives (D-FLPR-6 forward-only; the GUARD gate's last clause). + expect(adapter).not.toMatch(/@deprecated|LEGACY_|_OLD\b|dualRun|fallbackFlag/i); + }); +}); + +describe("DEM-PLACEHOLDER-GUARD: the negative test — the guard FAILS on a reintroduced literal", () => { + it.each([ + ["a bare literal", `export const x: Partial = { haFlatBackConfirmed: false };`, "haFlatBackConfirmed"], + ["an `as const` literal", `export const weeklyBias = "neutral" as const; export const y = { weeklyBias };`, "weeklyBias"], + ["a synthesized conditional", `declare const a: boolean; export const z = { rrMultiplePlanned: a ? 2 : 1 };`, "rrMultiplePlanned"], + ["a nullish fallback to a constant", `declare const t: { brokeEmaWithBody?: boolean }; export const w = { brokeEmaWithBody: t.brokeEmaWithBody ?? false };`, "brokeEmaWithBody"], + ["a variable declaration", `export const dailyBias = "neutral";`, "dailyBias"], + ["an assignment expression", `declare const o: FroggyTrendPullbackInput; export function f() { o.triggerPatternQuality = 0; }`, "triggerPatternQuality"], + ])("catches %s", async (_label, body, field) => { + const findings = await scan(fixture(body)); + expect(findings.map((f) => f.field)).toContain(field); + }, 60_000); + + it("catches the exact retired form: a constant IMPORTED ACROSS THE PACKAGE BOUNDARY (a .d.ts literal type)", async () => { + // This is the form D-DEM-4(2) named at afi-reactor laneView.ts:79 — an + // identifier whose initializer lives in another package's declarations. + // A syntax-only guard cannot see it; the type checker can. + const findings = await scan([ + { + fileName: path.join(REPO_ROOT, "analysts", "__guard_stub__.ts"), + content: `export const REINTRODUCED_STUB = false as const;\n`, + }, + { + fileName: path.join(REPO_ROOT, "analysts", "__guard_fixture__.ts"), + content: + `import { REINTRODUCED_STUB } from "./__guard_stub__.js";\n` + + `export const v = { brokeEmaWithBody: REINTRODUCED_STUB };\n`, + }, + ]); + const hit = findings.find((f) => f.field === "brokeEmaWithBody"); + expect(hit).toBeDefined(); + expect(hit!.reason).toMatch(/literal type/); + }, 60_000); +}); + +describe("DEM-PLACEHOLDER-GUARD: the exemptions the accepted text states", () => { + it("does NOT fire on liquiditySwept — the reserved two-lane computation (D-DEM-3(5))", async () => { + const findings = await scan(fixture(`export const q = { liquiditySwept: false };`)); + expect(findings.map((f) => f.field)).not.toContain("liquiditySwept"); + }, 60_000); + + it("does NOT fire on a mapping-sourced value: an interpreter fragment is not a constant", async () => { + const findings = await scan( + fixture( + `declare const fragment: Record;\n` + + `export const r = { weeklyBias: fragment.weeklyBias, rrMultiplePlanned: fragment.rrMultiplePlanned };` + ) + ); + expect(findings).toEqual([]); + }, 60_000); + + it("does NOT fire on a mapping DOCUMENT's own declared literals (band ordinals, recode targets, defaults)", async () => { + // A mapping document is DATA: its keys are binding targets, not scorer-input + // assignments, and its literals are the registered values themselves. + const findings = await scan( + fixture( + `export const doc = { bindings: { weeklyBias: { operator: "recode", table: { bullish: "long" }, fallback: "neutral", absent: "neutral" },\n` + + ` triggerPatternQuality: { operator: "band", rows: [{ when: { gte: 75 }, value: 3 }], otherwise: 0, absent: 0 },\n` + + ` rrMultiplePlanned: { operator: "bind", optionality: { ground: "producer-declared", default: 1 } } } };` + ) + ); + // `weeklyBias:` / `triggerPatternQuality:` / `rrMultiplePlanned:` here are + // keys of a mapping document whose values are OBJECTS — not constants — + // so nothing fires. + expect(findings).toEqual([]); + }, 60_000); + + it("does NOT fire on test fixtures that build inputs by hand (test dirs are exempt)", async () => { + const findings = await scan([ + { + fileName: path.join(REPO_ROOT, "analysts", "__tests__", "__guard_fixture__.test.ts"), + content: `export const t = { weeklyBias: "neutral", haFlatBackConfirmed: false };\n`, + }, + ]); + expect(findings).toEqual([]); + }, 60_000); + + it("does NOT fire on the composer's predicate domain table (predicates are not constants)", async () => { + const findings = await scan( + fixture( + `const isBias = (v: unknown) => v === "long";\n` + + `export const table = { weeklyBias: { describe: "long|short|neutral", test: isBias } };` + ) + ); + // The value is an object; the field name is a key of a lookup table. + expect(findings).toEqual([]); + }, 60_000); +}); diff --git a/validators/PlaceholderLiteralGuard.ts b/validators/PlaceholderLiteralGuard.ts new file mode 100644 index 0000000..3b0536c --- /dev/null +++ b/validators/PlaceholderLiteralGuard.ts @@ -0,0 +1,320 @@ +/** + * DEM-PLACEHOLDER-GUARD — the terminal enforcement of D-DEM-4(5) + * (DEM-GOV §9 DEM-PLACEHOLDER-GUARD, owner-authorized 2026-08-25): + * + * "a CI guard asserts that no expression in `afi-core/analysts/**` or + * `afi-reactor/src/pipeline/nodes/**` assigns a `FroggyTrendPullbackInput` + * field from a compile-time constant, and a negative test proves the guard + * fails on a reintroduced one. Values sourced from a registered mapping + * document are expressly exempt — including its declared `default` + * literals, `band` output ordinals, `recode` target members, and + * `namespace-default` empty objects — as is the reserved `liquiditySwept` + * computation, which is not a literal." + * + * HOW IT DECIDES. The field set is read from the `FroggyTrendPullbackInput` + * interface itself, so it can never drift from the rubric's contract. A + * finding is an assignment to one of those field NAMES whose value is + * CONSTANT — decided by the TYPE CHECKER as well as the syntax tree, so a + * constant imported across a package boundary (the exact form the retired + * `BROKE_EMA_WITH_BODY_UNIMPLEMENTED_STUB` took at `laneView.ts:79`, where the + * initializer lives in another repo's `.d.ts`) is caught just like a local + * literal. Mapping-sourced values are never constant — they flow from an + * interpreter result at runtime — so the exemption holds by construction. + * + * The `typescript` compiler API is imported LAZILY so this module stays inert + * (and dependency-free at runtime) unless a test actually invokes it. + */ + +export interface PlaceholderFinding { + /** Repo-relative path of the offending file. */ + file: string; + /** 1-indexed line. */ + line: number; + /** The scorer-input field being assigned. */ + field: string; + /** The offending source text (trimmed). */ + text: string; + /** Why it is a constant. */ + reason: string; +} + +export interface GuardOptions { + /** tsconfig whose program the scan runs in (its `include` set is scanned). */ + tsconfigPath: string; + /** Directories (repo-relative) the guard is bounded to, per D-DEM-4(5). */ + bounds: string[]; + /** Repo root the reported paths are relative to. */ + repoRoot: string; + /** + * Fields exempt by the accepted text: `liquiditySwept` (the reserved + * two-lane computation, D-DEM-3(5)). + */ + exemptFields?: string[]; + /** + * D-DEM-4(5)'s mapping-sourced exemption, made checkable: the REGISTERED + * mapping's declared `default` literals, keyed by binding target. A constant + * is exempt only where its value EQUALS the registered default for that same + * target — so the grandfathered `?? 0` / `?? false` the retired adapter + * still applies (its physical deletion is reserved by DEM-BIND ruling R1) + * cannot drift from the registered document, and any OTHER literal at the + * same site still fires. + */ + mappingDeclaredDefaults?: Record; + /** Extra in-memory sources to scan (negative-test fixtures). */ + extraSources?: Array<{ fileName: string; content: string }>; +} + +const DEFAULT_EXEMPT_FIELDS = ["liquiditySwept"]; + +/** Test directories are exempt: fixtures legitimately build inputs by hand. */ +function isTestPath(p: string): boolean { + return /(^|[\\/])(__tests__|__mocks__|test|tests)[\\/]/.test(p) || /\.(test|spec)\.[cm]?tsx?$/.test(p); +} + +function toPosix(p: string): string { + return p.replace(/\\/g, "/"); +} + +export async function scanForPlaceholderLiterals(options: GuardOptions): Promise { + const ts = (await import("typescript")).default ?? (await import("typescript")); + const path = await import("node:path"); + + const configFile = ts.readConfigFile(options.tsconfigPath, ts.sys.readFile); + if (configFile.error) { + throw new Error(`PlaceholderLiteralGuard: cannot read ${options.tsconfigPath}`); + } + const parsed = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + path.dirname(options.tsconfigPath) + ); + + const extra = options.extraSources ?? []; + const extraByName = new Map(extra.map((s) => [toPosix(path.resolve(s.fileName)), s.content])); + const rootNames = [...parsed.fileNames, ...extra.map((s) => path.resolve(s.fileName))]; + + // A host that serves the in-memory fixtures and delegates everything else to + // disk, so a negative-test fixture typechecks against the real declarations. + const host = ts.createCompilerHost(parsed.options, true); + const originalGetSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => { + const injected = extraByName.get(toPosix(path.resolve(fileName))); + if (injected !== undefined) { + return ts.createSourceFile(fileName, injected, languageVersion, true); + } + return originalGetSourceFile(fileName, languageVersion, onError, shouldCreate); + }; + const originalFileExists = host.fileExists.bind(host); + host.fileExists = (fileName) => + extraByName.has(toPosix(path.resolve(fileName))) || originalFileExists(fileName); + const originalReadFile = host.readFile.bind(host); + host.readFile = (fileName) => + extraByName.get(toPosix(path.resolve(fileName))) ?? originalReadFile(fileName); + + const program = ts.createProgram(rootNames, parsed.options, host); + const checker = program.getTypeChecker(); + + // The authoritative field set: the rubric's own input interface. + const fields = readInputFieldNames(ts, program); + if (fields.size === 0) { + throw new Error("PlaceholderLiteralGuard: could not resolve FroggyTrendPullbackInput's fields"); + } + const exempt = new Set(options.exemptFields ?? DEFAULT_EXEMPT_FIELDS); + const bounds = options.bounds.map((b) => toPosix(path.resolve(options.repoRoot, b))); + const findings: PlaceholderFinding[] = []; + + for (const sourceFile of program.getSourceFiles()) { + const abs = toPosix(path.resolve(sourceFile.fileName)); + if (sourceFile.isDeclarationFile) continue; + if (!bounds.some((b) => abs === b || abs.startsWith(`${b}/`))) continue; + if (isTestPath(abs)) continue; + + const rel = toPosix(path.relative(options.repoRoot, abs)); + const visit = (node: import("typescript").Node): void => { + // `{ field: }` and shorthand `{ field }` + if (ts.isPropertyAssignment(node) && isFieldName(ts, node.name, fields, exempt)) { + record(rel, node.name.getText(sourceFile), node.initializer, node); + } else if (ts.isShorthandPropertyAssignment(node) && fields.has(node.name.text) && !exempt.has(node.name.text)) { + const symbol = checker.getShorthandAssignmentValueSymbol(node); + const decl = symbol?.valueDeclaration; + if (decl && ts.isVariableDeclaration(decl) && decl.initializer) { + record(rel, node.name.text, decl.initializer, node); + } + } else if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && fields.has(node.name.text) && !exempt.has(node.name.text) && node.initializer) { + record(rel, node.name.text, node.initializer, node); + } else if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.EqualsToken && + ts.isPropertyAccessExpression(node.left) && + fields.has(node.left.name.text) && + !exempt.has(node.left.name.text) + ) { + record(rel, node.left.name.text, node.right, node); + } + ts.forEachChild(node, visit); + }; + + function record( + file: string, + field: string, + expr: import("typescript").Expression, + at: import("typescript").Node + ): void { + const reason = constancyReason(ts, checker, expr); + if (!reason) return; + // D-DEM-4(5): a value equal to the REGISTERED mapping's declared default + // for this same target is expressly exempt. Any other constant fires. + const declared = options.mappingDeclaredDefaults?.[field]; + if (declared !== undefined && constantValue(ts, checker, expr) === declared) return; + const { line } = sourceFile.getLineAndCharacterOfPosition(at.getStart(sourceFile)); + findings.push({ + file, + line: line + 1, + field, + text: at.getText(sourceFile).replace(/\s+/g, " ").slice(0, 160), + reason, + }); + } + + ts.forEachChild(sourceFile, visit); + } + + return findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); +} + +function isFieldName( + ts: typeof import("typescript"), + name: import("typescript").PropertyName, + fields: Set, + exempt: Set +): boolean { + const text = ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined; + return text !== undefined && fields.has(text) && !exempt.has(text); +} + +/** Read the scorer input's field names straight from its interface. */ +function readInputFieldNames( + ts: typeof import("typescript"), + program: import("typescript").Program +): Set { + const out = new Set(); + for (const sf of program.getSourceFiles()) { + ts.forEachChild(sf, (node) => { + if (ts.isInterfaceDeclaration(node) && node.name.text === "FroggyTrendPullbackInput") { + for (const member of node.members) { + if (member.name && (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name))) { + out.add(member.name.text); + } + } + } + }); + } + return out; +} + +/** + * Is this expression a compile-time constant? Syntax first (literals, `as + * const`, unary of a literal, substitution-free templates, `??`/ternary of + * constants), then the TYPE CHECKER — a literal type from any declaration, + * including a `.d.ts` in another package, is exactly the cross-package form + * D-DEM-4(2) named. Returns the reason, or undefined when it is not constant. + */ +function constancyReason( + ts: typeof import("typescript"), + checker: import("typescript").TypeChecker, + expr: import("typescript").Expression +): string | undefined { + const node = unwrap(ts, expr); + + if ( + ts.isStringLiteral(node) || + ts.isNumericLiteral(node) || + ts.isBigIntLiteral(node) || + node.kind === ts.SyntaxKind.TrueKeyword || + node.kind === ts.SyntaxKind.FalseKeyword || + node.kind === ts.SyntaxKind.NullKeyword || + (ts.isIdentifier(node) && node.text === "undefined") + ) { + return "literal"; + } + if (ts.isNoSubstitutionTemplateLiteral(node)) return "template literal without substitution"; + if (ts.isPrefixUnaryExpression(node) && constancyReason(ts, checker, node.operand)) { + return "unary of a literal"; + } + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) { + // `x ?? CONST` — the fallback half is the placeholder (the retired `??` + // stub form); flag when the fallback is constant. + const r = constancyReason(ts, checker, node.right); + if (r) return `nullish fallback to a ${r}`; + } + if (ts.isConditionalExpression(node)) { + const w = constancyReason(ts, checker, node.whenTrue); + const f = constancyReason(ts, checker, node.whenFalse); + if (w && f) return "conditional between constants"; + } + + // The type-checker pass: a literal type (from an initializer here, or from a + // `.d.ts` across a package boundary) means the value cannot vary at runtime. + if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node)) { + const type = checker.getTypeAtLocation(node); + const resolved = checker.getBaseConstraintOfType(type) ?? type; + const LITERAL_FLAGS = + ts.TypeFlags.StringLiteral | + ts.TypeFlags.NumberLiteral | + ts.TypeFlags.BigIntLiteral | + ts.TypeFlags.BooleanLiteral | + ts.TypeFlags.Undefined | + ts.TypeFlags.Null; + if (!resolved.isUnion() && (resolved.flags & LITERAL_FLAGS) !== 0) { + return `identifier of literal type '${checker.typeToString(resolved)}'`; + } + } + return undefined; +} + +/** + * The literal VALUE of a constant expression (for the mapping-default + * exemption), or undefined when it has none. For `x ?? CONST` the fallback is + * the constant that matters — that is the grandfathered absent-source form. + */ +function constantValue( + ts: typeof import("typescript"), + checker: import("typescript").TypeChecker, + expr: import("typescript").Expression +): string | number | boolean | undefined { + const node = unwrap(ts, expr); + if (ts.isStringLiteral(node)) return node.text; + if (ts.isNumericLiteral(node)) return Number(node.text); + if (node.kind === ts.SyntaxKind.TrueKeyword) return true; + if (node.kind === ts.SyntaxKind.FalseKeyword) return false; + if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken) { + const inner = constantValue(ts, checker, node.operand); + return typeof inner === "number" ? -inner : undefined; + } + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) { + return constantValue(ts, checker, node.right); + } + if (ts.isIdentifier(node) || ts.isPropertyAccessExpression(node)) { + const type = checker.getTypeAtLocation(node); + const resolved = checker.getBaseConstraintOfType(type) ?? type; + if (resolved.isStringLiteral() || resolved.isNumberLiteral()) return resolved.value; + if ((resolved.flags & ts.TypeFlags.BooleanLiteral) !== 0) { + return checker.typeToString(resolved) === "true"; + } + } + return undefined; +} + +function unwrap( + ts: typeof import("typescript"), + expr: import("typescript").Expression +): import("typescript").Expression { + let node: import("typescript").Expression = expr; + for (;;) { + if (ts.isParenthesizedExpression(node)) node = node.expression; + else if (ts.isAsExpression(node) || ts.isSatisfiesExpression(node)) node = node.expression; + else if (ts.isTypeAssertionExpression?.(node)) node = node.expression; + else if (ts.isNonNullExpression(node)) node = node.expression; + else return node; + } +}