Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions analysts/__tests__/froggy.residual_builder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* DEM-BIND (a'): the residual builder + composer.
*
* The load-bearing property: for every enriched view,
* compose(interpret(registeredMapping, view).fragment, residual(view))
* byte-equals the untouched legacy adapter's FULL output. This is the
* equivalence DEM-BIND's runtime seam relies on, proven here at the afi-core
* layer against the same adapter export the FLPR-GOV guards pin.
*/

import { existsSync, readFileSync } from "node:fs";
import { describe, it, expect } from "vitest";
import {
buildFroggyResidualInput,
composeFroggyTrendPullbackInput,
} from "../froggy.residual_builder.js";
import {
buildFroggyTrendPullbackInputFromEnriched,
type FroggyEnrichedView,
} from "../froggy.enrichment_adapter.js";
import { interpretEnrichmentMapping } from "../../validators/EnrichmentMappingInterpreter.js";

const SIBLING_MAPPING = new URL(
"../../../afi-config/examples/enrichment-mapping/v1/enrichment-mapping.example.json",
import.meta.url
).pathname;

/** Inline copy of the canonical froggy mapping's bindings (byte-locked to the
* afi-config example by the DEM-CONTRACT interpreter test's sibling describe;
* duplicated minimally here so this suite stays hermetic). */
function froggyMapping(): Record<string, unknown> {
if (existsSync(SIBLING_MAPPING)) {
return JSON.parse(readFileSync(SIBLING_MAPPING, "utf8"));
}
return {
schema: "afi.enrichment-mapping.v1",
mappingId: "froggy-trend-pullback",
version: "1.0.0",
namespaceDefaults: ["technical", "pattern"],
bindings: {
distanceFromDailyEmaPct: {
operator: "bind",
source: { lane: "technical", path: "emaDistancePct" },
type: "number",
optionality: { ground: "grandfather", default: 0 },
},
pulledBackIntoSweetSpot: {
operator: "bind",
source: { lane: "technical", path: "isInValueSweetSpot" },
type: "boolean",
optionality: { ground: "grandfather", default: false },
},
triggerPatternQuality: {
operator: "band",
source: { lane: "pattern", path: "patternConfidence" },
rows: [
{ when: { gte: 75 }, value: 3 },
{ when: { gte: 65 }, value: 2 },
{ when: { gt: 0 }, value: 1 },
],
otherwise: 0,
absent: 0,
},
atrRegime: {
operator: "recode",
source: { lane: "technical", path: "atrRegime" },
table: { low: "low", high: "high", extreme: "extreme" },
fallback: "normal",
absent: "normal",
},
},
};
}

function view(overrides: Partial<FroggyEnrichedView>): FroggyEnrichedView {
return {
signalId: "sig-residual-test",
symbol: "BTCUSDT",
market: "crypto",
timeframe: "4h",
technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "low" },
pattern: { patternName: "bull flag", patternConfidence: 80 },
sentiment: { score: 0.4, tags: ["liquidity sweep"] },
...overrides,
};
}

/** The full-domain probe set: every adapter branch the mapping expresses,
* plus residual-relevant variation (sweep hints on/off). */
const PROBES: Array<[string, FroggyEnrichedView]> = [
["baseline", view({})],
["band 75", view({ pattern: { patternConfidence: 75 } })],
["band 74.999", view({ pattern: { patternConfidence: 74.999 } })],
["band 65", view({ pattern: { patternConfidence: 65 } })],
["band 0.5", view({ pattern: { patternConfidence: 0.5 } })],
["band present-zero", view({ pattern: { patternConfidence: 0 } })],
["band absent", view({ pattern: { patternName: "bull flag" } })],
["recode high", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "high" } })],
["recode extreme", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "extreme" } })],
["recode present-normal", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "normal" } })],
["recode unrecognized", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true, atrRegime: "weird" } })],
["recode absent", view({ technical: { emaDistancePct: 1.5, isInValueSweetSpot: true } })],
["grandfathers fire (empty technical)", view({ technical: {} })],
["technical namespace missing", (() => { const v = view({}); delete (v as Record<string, unknown>).technical; return v; })()],
["pattern namespace missing", (() => { const v = view({}); delete (v as Record<string, unknown>).pattern; return v; })()],
["null namespace", view({ technical: null } as unknown as Partial<FroggyEnrichedView>)],
["null-valued sources", view({ technical: { emaDistancePct: null, isInValueSweetSpot: null, atrRegime: null } } as unknown as Partial<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" } })],
];

describe("DEM-BIND (a'): compose(fragment, residual) === legacy adapter, byte-for-byte", () => {
for (const [label, v] of PROBES) {
it(label, () => {
const mapping = froggyMapping();
const result = interpretEnrichmentMapping(mapping, v);
const residual = buildFroggyResidualInput(v);
const composed = composeFroggyTrendPullbackInput(result.fragment, residual);
const legacy = buildFroggyTrendPullbackInputFromEnriched(v);
expect(composed).toStrictEqual(legacy);
});
}

it("the residual carries exactly the six unexpressible fields", () => {
const residual = buildFroggyResidualInput(view({}));
expect(Object.keys(residual).sort()).toEqual([
"brokeEmaWithBody",
"dailyBias",
"haFlatBackConfirmed",
"liquiditySwept",
"rrMultiplePlanned",
"weeklyBias",
]);
});
});

describe("DEM-BIND (a'): the composer fails closed (D-DEM-5(2))", () => {
const residual = buildFroggyResidualInput(view({}));

it("refuses a fragment missing a declared target", () => {
expect(() =>
composeFroggyTrendPullbackInput(
{ distanceFromDailyEmaPct: 1, pulledBackIntoSweetSpot: true, triggerPatternQuality: 2 },
residual
)
).toThrow(/exactly/);
});

it("refuses a fragment carrying an extra target", () => {
expect(() =>
composeFroggyTrendPullbackInput(
{ distanceFromDailyEmaPct: 1, pulledBackIntoSweetSpot: true, triggerPatternQuality: 2, atrRegime: "low", extra: 1 },
residual
)
).toThrow(/exactly/);
});

it("refuses wrong-typed targets, including an out-of-grade integer", () => {
expect(() =>
composeFroggyTrendPullbackInput(
{ distanceFromDailyEmaPct: "1", pulledBackIntoSweetSpot: true, triggerPatternQuality: 2, atrRegime: "low" },
residual
)
).toThrow(/number/);
expect(() =>
composeFroggyTrendPullbackInput(
{ distanceFromDailyEmaPct: 1, pulledBackIntoSweetSpot: true, triggerPatternQuality: 7, atrRegime: "low" },
residual
)
).toThrow(/0\|1\|2\|3/);
});
});
129 changes: 129 additions & 0 deletions analysts/froggy.residual_builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* DEM-BIND: the residual (unexpressible) input builder + the composer that
* assembles the froggy scorer input from an interpreted mapping fragment plus
* the residual half (DEM-GOV D-DEM-2(6)(7); §9 slot DEM-BIND, owner-authorized
* 2026-08-22).
*
* WHY THIS MODULE EXISTS, AND WHY THE ADAPTER IS NOT EDITED:
* `froggy.enrichment_adapter.ts` stays byte-identical — the DEM-BIND gate
* requires the FLPR-GOV inertness guards and the oracle/byte-equivalence
* suites (which import the adapter export as their oracle) to pass UNCHANGED,
* and the owner directive requires the unexpressible half left in place. So
* the residual builder CALLS the untouched adapter and picks only the six
* fields the mapping cannot express (D-DEM-3): the five placeholders whose
* real producers are the DEM-PRODUCER slots, plus liquiditySwept (a two-lane
* read, inexpressible by construction — D-DEM-3(5), expressly reserved).
*
* On the live mapping path the adapter's expressible computation still
* executes here and its expressible outputs are DISCARDED — only the
* interpreter fragment is authoritative (D-DEM-2(6): "no code-path fallback
* to the retired bespoke code" — a discarded value is not a fallback; where a
* determination exists the fragment is unconditionally authoritative, and an
* interpreter refusal means no determination at all, D-DEM-5(7)).
*/

import type { FroggyEnrichedView } from "./froggy.enrichment_adapter.js";
import { buildFroggyTrendPullbackInputFromEnriched } from "./froggy.enrichment_adapter.js";
import type { FroggyTrendPullbackInput } from "./froggy.trend_pullback_v1.js";
import type { EnrichmentMappingResult } from "../validators/EnrichmentMappingInterpreter.js";

/** The six fields the mapping cannot express (D-DEM-3; DEM-PRODUCER-reserved
* placeholders + the reserved two-lane liquiditySwept). */
export type FroggyResidualInput = Pick<
FroggyTrendPullbackInput,
| "weeklyBias"
| "dailyBias"
| "haFlatBackConfirmed"
| "brokeEmaWithBody"
| "rrMultiplePlanned"
| "liquiditySwept"
>;

/** The four targets the registered froggy mapping declares (D-DEM-3(4)). */
const EXPRESSIBLE_TARGETS = [
"distanceFromDailyEmaPct",
"pulledBackIntoSweetSpot",
"triggerPatternQuality",
"atrRegime",
] as const;

type ExpressibleTarget = (typeof EXPRESSIBLE_TARGETS)[number];

/**
* Build the residual half by running the untouched legacy adapter and picking
* the six unexpressible fields from its output. The expressible outputs are
* discarded (see the module header for the D-DEM-2(6) defense).
*/
export function buildFroggyResidualInput(
enriched: FroggyEnrichedView
): FroggyResidualInput {
const full = buildFroggyTrendPullbackInputFromEnriched(enriched);
return {
weeklyBias: full.weeklyBias,
dailyBias: full.dailyBias,
haFlatBackConfirmed: full.haFlatBackConfirmed,
brokeEmaWithBody: full.brokeEmaWithBody,
rrMultiplePlanned: full.rrMultiplePlanned,
liquiditySwept: full.liquiditySwept,
};
}

/**
* Compose the scorer input from an interpreted mapping fragment plus the
* residual half. Fail-closed (D-DEM-5(2)): the fragment must carry EXACTLY
* the four declared targets with the declared types — anything else throws;
* no defaulted, partial, or fabricated input ever reaches the scorer.
*/
export function composeFroggyTrendPullbackInput(
fragment: EnrichmentMappingResult["fragment"],
residual: FroggyResidualInput
): FroggyTrendPullbackInput {
const keys = Object.keys(fragment).sort();
const expected = [...EXPRESSIBLE_TARGETS].sort();
if (
keys.length !== expected.length ||
!expected.every((k, i) => keys[i] === k)
) {
throw new Error(
`composeFroggyTrendPullbackInput: fragment must carry exactly ` +
`[${expected.join(", ")}], got [${keys.join(", ")}] — refusing to ` +
`compose (fail-closed, D-DEM-5(2)).`
);
}
const distanceFromDailyEmaPct = fragment["distanceFromDailyEmaPct" satisfies ExpressibleTarget];
const pulledBackIntoSweetSpot = fragment["pulledBackIntoSweetSpot" satisfies ExpressibleTarget];
const triggerPatternQuality = fragment["triggerPatternQuality" satisfies ExpressibleTarget];
const atrRegime = fragment["atrRegime" satisfies ExpressibleTarget];
if (typeof distanceFromDailyEmaPct !== "number") {
throw new Error(
`composeFroggyTrendPullbackInput: distanceFromDailyEmaPct must be a number, got ${typeof distanceFromDailyEmaPct} — refusing (D-DEM-5(2)).`
);
}
if (typeof pulledBackIntoSweetSpot !== "boolean") {
throw new Error(
`composeFroggyTrendPullbackInput: pulledBackIntoSweetSpot must be a boolean, got ${typeof pulledBackIntoSweetSpot} — refusing (D-DEM-5(2)).`
);
}
if (
triggerPatternQuality !== 0 &&
triggerPatternQuality !== 1 &&
triggerPatternQuality !== 2 &&
triggerPatternQuality !== 3
) {
throw new Error(
`composeFroggyTrendPullbackInput: triggerPatternQuality must be 0|1|2|3 (the D-EQ-2 grade set), got ${String(triggerPatternQuality)} — refusing (D-DEM-5(2)).`
);
}
if (typeof atrRegime !== "string") {
throw new Error(
`composeFroggyTrendPullbackInput: atrRegime must be a string, got ${typeof atrRegime} — refusing (D-DEM-5(2)).`
);
}
return {
...residual,
distanceFromDailyEmaPct,
pulledBackIntoSweetSpot,
triggerPatternQuality,
atrRegime: atrRegime as FroggyTrendPullbackInput["atrRegime"],
};
}
Loading