PowerFill Module — Phase 5 (runtime carry-cost calculator)
Date: 2026-04-16
Agent: Systems Architect (with Collaborator review and PO approval)
Scope: Runtime PowerFillCarryCostCalculator C# service that consumes Phase 4-configured pfill_carry_cost curves to compute carry_cost and prx_plus_carry for (loan, trade) candidate pairs. Diagnostic POST /api/powerfill/carry-cost/preview endpoint. Spec amendment ("Carry Cost Computation" section) + assumptions log updates (A11 verified, A34 carry sign, A35 PS_DemoData placeholder rates). Phase 6 (allocation engine) prerequisite.
Why
The Phase 4 CRUD APIs let operators configure pfill_carry_cost curves; Phase 5 makes those curves runnable — turning configuration into an actual carry_cost number for each (loan, trade) candidate pair. Phase 6's allocation engine scores candidates by prx_plus_carry (price plus carry); without a working calculator, the allocation pass cannot rank correctly. Per the PowerFill spec roadmap (line 433), Phase 5's calculator is the prerequisite for the biggest phase of the module.
The legacy NVO computes carry inside the allocation T-SQL (psp_powerfill_conset lines 1332-1364, re-emitted at psp_powerfillUE lines 14429-14472). PSSaaS implements the calculator in C# per ADR-021's hybrid model — module orchestration in C#, allocation engine T-SQL deferred to Phase 6. Phase 5's C# service becomes the parity reference for whatever path Phase 6 chooses (pre-compute in C# and bulk insert, or port the formula to T-SQL with the C# calculator as the test oracle).
What was done
Algorithm (verbatim NVO port, financial-precision-critical)
The legacy formula reduces to three steps per (loan, trade) input:
- Average rate lookup —
AVG(pcc.annual_rate) WHERE pcc.investor_instrument_name = candidate.market AND candidate.interest_earning_days BETWEEN pcc.on_day AND pcc.to_day AND pcc.annual_rate IS NOT NULL. AVG is defensive against schema permitting overlapping buckets (the PK(investor_instrument_name, on_day)allows distinct on_day with overlapping to_day). - carry_cost =
round_NUMERIC(9,6)(avg_rate * interest_earning_days / 365)withMidpointRounding.AwayFromZeroto match T-SQLCASTsemantics for Phase 9 parity. - prx_plus_carry =
price + (mode == 'po' ? 0 : (note_rate * days/365 - carry_cost)). The'po'(PriceOnly) mode short-circuits the entire net-carry adjustment to zero;'pc'(PricePlusCarry) adds the loan's net interest income and subtracts cost-of-funds carry.
/365 preserves decimal precision because interest_earning_days is declared NUMERIC(4,0) in every legacy temp table (NVO lines 216, 244, 437); T-SQL datatype precedence promotes NUMERIC(4,0) / INT to NUMERIC. Empirically confirmed against pssaas-db with the literal NVO line 1361-1364 expression: prx_plus_carry for (price=99.5, note_rate=6.25, days=15, carry_cost=0.014795, mode='pc') returns 99.742049, NOT price - carry_cost = 99.485205.
C# service surface
PowerFillCarryCostCalculatorinModules.PowerFill/Services/. Puredecimalarithmetic (CLAUDE.md invariant #5). One DB round-trip perComputeAsyncbatch (groups inputs by distinct trimmed market; singleIN @marketsquery against the PK).CarryCostInputrecord (loan_id, trade_id, market, interest_earning_days, price, note_rate).CarryCostResultrecord (loan_id, trade_id, average_annual_rate, carry_cost, prx_plus_carry, match_status, matched_row_count). Thematch_statusenum is diagnostic only — never a filter verdict, per assumption A2. Phase 6 owns ranking decisions for NULL-scored candidates.- DI registration scoped per request, tenant-isolated via
TenantDbContext(ADR-005). Trim()defense on both sides of the join key for CHAR-vs-VARCHAR drift (A29). Secondary fallback query if the indexedINclause misses due to padding.
HTTP surface
POST /api/powerfill/carry-cost/preview— diagnostic endpoint that takes a batch of(loan_id, trade_id, market, interest_earning_days, price, note_rate)and returns the calculator output. Pure transformation; does not look up real loan/trade data. Symmetric with Phase 4'sGET /settings/preflight"diagnostic visibility" pattern. Lets operators and developers PoC-verify the calculator against any tenant's configured curves without running a full PowerFill.
Tests (27 unit tests, in-memory)
PowerFillCarryCostCalculatorTests covers:
- U1-U6 — happy path, multi-row averaging, bucket-boundary semantics (closed inclusive
[on_day, to_day]) - E1-E12 — edge cases: negative days, missing instrument, day-bucket gap, days exceeds max, NULL annual_rate (mixed and all-null), NULL
to_daytreated as +∞ (defensive extension), PriceOnly mode + match success/failure, price=0, empty curve, empty input list - R1-R2 — decimal rounding parity (
MidpointRounding.AwayFromZeromatches T-SQLCAST(decimal AS NUMERIC(9,6))); nodouble/floatin the calculator file (source-text grep) - T1 —
Trim()defense for CHAR-padding variations - B1 — batched query across multiple instruments preserves input order
- NvoParity — pin the carry_cost = 0.014795 / prx_plus_carry ≈ 99.742054 reference values
All 27 pass. Full suite: 110/110 unit tests pass + 4 SQL integration tests skipped (env-var gated, expected). 0 build warnings. Verification commands run inside pssaas-api container per Phase 4 lessons.
Live PoC against PS_DemoData (kickoff success criterion)
curl -s -H "X-Tenant-Id: ps-demodata" -H "Content-Type: application/json" \
-d '{"items":[{"loan_id":"L1","trade_id":"T1","market":"30 fnma cash","interest_earning_days":20,"price":99.5,"note_rate":6.25}],"price_mode":"PricePlusCarry"}' \
"http://pssaas.powerseller.local/api/powerfill/carry-cost/preview"
Returns:
{"results":[{"loan_id":"L1","trade_id":"T1","average_annual_rate":0.270000,
"carry_cost":0.014795,"prx_plus_carry":99.827671,
"match_status":"Matched","matched_row_count":1}]}
Math check: 0.27 × 20 / 365 = 0.014794520... → 0.014795 ✓; 99.5 + (6.25 × 20/365 - 0.014795) = 99.827670... → 99.827671 (AwayFromZero) ✓. Kickoff success criterion met.
Edge-case PoC also verified live: InstrumentNotInCurve, DaysOutsideCoverage, and PriceOnly mode behavior all return the documented shapes.
Spec + assumptions log
powerfill-engine.md— added "Carry Cost Computation (Phase 5)" sub-section under §Carry Cost Configuration with the formal algorithm, edge-case table, sign convention, decimal precision note, and'any'semantics. AddedPOST /carry-cost/previewto a new "Calculator APIs (Phase 5)" sub-section in the API Contracts table.powerfill-assumptions-log.md:- A11 updated in-place — formula verified, no longer needs Tom/Greg confirmation on the algorithm. Phase 0's "days × rate" framing was incomplete (missed
/365divisor and dual-modeprx_plus_carry). - A34 added — carry sign convention: net-interest income added to price, cost-of-funds carry subtracted,
'po'mode short-circuits toprx_plus_carry = price. Resolves A14's open carry-sign question. - A35 added — PS_DemoData carry-cost rates are uniform
0.270000placeholders with identical 3-bucket coverage[0..15][16..30][31..45]. Integration tests prove shape, not economic correctness; that's Phase 9 parallel-validation. - Summary of Open Questions updated: A11 and A14 marked RESOLVED; new questions about
'any'wildcard semantics and rounding-mode parity added for Tom/Greg.
- A11 updated in-place — formula verified, no longer needs Tom/Greg confirmation on the algorithm. Phase 0's "days × rate" framing was incomplete (missed
Process discipline incidents
Two notable incidents during the Architect ↔ Collaborator review cycle, both worth recording for honest provenance.
Empirical-citation type mismatch (twice in one phase)
The Architect's first plan revision included an empirical citation in §3.4 — CAST(15 AS NUMERIC(4,0)) / 365 = 0.041095 — as evidence for behavior of an expression whose actual concern would be INT/INT truncation. Different expression, different type signature, different result. Collaborator review caught the mismatch (C2).
The Collaborator's C1 review then surfaced a hypothetical concern that (interest_earning_days/365) at NVO line 1364 would integer-truncate, citing SELECT 15/365 = 0 as evidence — making the same class of error: that empirical test uses INT literals, but the legacy expression uses a NUMERIC(4,0) variable that promotes the division to NUMERIC regardless of parens.
Architect ran the literal NVO expression with realistic types against pssaas-db (returned 99.742049, not the truncation theory's predicted 99.485205) and grep-traced all 12 declarations of interest_earning_days in n_cst_powerfill.sru (every one is NUMERIC(4,0) or NUMERIC(6,0), never INT). C1 rescinded.
Outcome: The Andon-cord exchange worked as designed — Architect didn't silently implement what would have been wrong code per ADR-006 parity, didn't silently dismiss the concern either, but ran the actual SQL with the actual production types and reported back. The pattern of "empirical citation for the wrong expression" surfaced twice in one phase, suggesting a candidate Process Discipline observation: when Primary-Source Verification cites SQL test output as evidence, the test must use the literal expression with the actual production variable types, not a synthetic substitute. To be nominated for v3.x after Phase 6 (or sooner if it surfaces a third time).
JSON binding bug discovery via live PoC
The first manual PoC against PS_DemoData returned {"loanId":null,"tradeId":null,"averageAnnualRate":0.270000,"carryCost":0.000000,"prxPlusCarry":99.5,...} — loanId null, carryCost zero. The averageAnnualRate and matchedRowCount=1 confirmed the curve query worked, but the inputs hadn't bound. Root cause: the calculator's DTOs used PascalCase property names without [JsonPropertyName] attributes, and ASP.NET's default JSON binder doesn't auto-map snake_case JSON keys to PascalCase C# properties. Phase 4's existing contracts use explicit [JsonPropertyName] snake_case attributes throughout; the calculator contracts didn't follow that pattern in their first revision.
Outcome: Caught by the manual PoC HTTP response, BEFORE commit. Per process discipline §8 Diagnostic-First Rule, evidence drove the fix. Added explicit [JsonPropertyName] attributes to all calculator records + JsonStringEnumConverter for the two enums (so "PricePlusCarry" is accepted as a string, not an integer). Re-ran the PoC, got the correct response.
This is also evidence for the broader §11 retro lesson: the unit tests passed (27/27) before the JSON bug was discovered. The unit tests bypassed JSON serialization entirely. The manual HTTP PoC was the only path to surface this bug. For Phase 6 onward, an HTTP-level integration test (or at minimum a serialization round-trip test) would have caught it earlier.
Test delegation skipped (in-line decision)
The Phase 5 plan §8 called for delegating the unit-test cluster to a fast subagent (matches "Boilerplate tests >5" Required Delegation Category). The Architect self-implemented all 27 tests instead. Justification: writing the full Template-2 prompt with all the algorithm context + the §5 edge-case table + the existing test-pattern reference would have taken longer than just writing the tests, and the subagent round-trip overhead exceeded the benefit. This IS a Delegation Skip per process-discipline v4.1, logged here for honest accounting; mitigation is that the tests are mechanical enough that the delegation benefit was small in this specific case. Will calibrate the threshold in Phase 6 (which has substantially more boilerplate, so delegation cost-benefit shifts).
Files produced / modified
New (Phase 5):
src/backend/PowerSeller.SaaS.Modules.PowerFill/Contracts/CarryCostContracts.cs— DTOs + enumssrc/backend/PowerSeller.SaaS.Modules.PowerFill/Services/PowerFillCarryCostCalculator.cs— core calculatorsrc/backend/PowerSeller.SaaS.Modules.PowerFill/Endpoints/CarryCostCalculatorEndpoints.cs— preview endpointsrc/backend/tests/PowerSeller.SaaS.Modules.PowerFill.Tests/Services/PowerFillCarryCostCalculatorTests.cs— 27 unit testsdocs-site/docs/devlog/2026-04-16-powerfill-phase-5.md— this entry
Modified:
src/backend/PowerSeller.SaaS.Modules.PowerFill/PowerFillModule.cs— DI registration + endpoint mapping; status string updateddocs-site/docs/specs/powerfill-engine.md— new "Carry Cost Computation (Phase 5)" sub-section + Calculator APIs row in API tabledocs-site/docs/specs/powerfill-assumptions-log.md— A11 updated in-place; A34 + A35 added; Open Questions summary refreshed.cursor/plans/powerfill-phase-5.plan.md— applied C2/C3 from Collaborator review; §11 retro strengthened with both empirical-citation incidents
Out of scope (deferred):
pssaas-dbintegration test gated byPFILL_TEST_SQLSERVER(deferred — 27 unit tests + 4 manual PoC HTTP responses already cover the surface; building a redundant SQL Server harness would be over-engineering per Outcome-Linked Retro)- PS_DemoData automated integration test gated by
PFILL_TEST_DEMODATA(deferred — the manual PoC commands captured above are the canonical reference; aWebApplicationFactorytest rig would be new test infrastructure for marginal incremental coverage)
Both deferrals can revert if Phase 6 development surfaces a need for automated regression nets at those layers.
Verification
Run inside the pssaas-api container per AGENTS.md technical lesson (and Phase 4's first-attempt regression):
docker exec pssaas-api dotnet build --nologo
# → Build succeeded. 0 Warning(s). 0 Error(s).
docker exec pssaas-api dotnet test --nologo --no-build
# → BestEx: Passed 32 / 32 / 0 skipped
# → Api: Passed 1 / 1 / 0 skipped
# → PowerFill: Passed 77 / 77 / 4 skipped (env-var gated SQL tests)
# → Total: 110 passed, 4 skipped, 0 failed
Live PoC verification commands captured above.
What's next
- Phase 6 — Core allocation engine (
psp_powerfill_consetport). Will be the biggest phase of the module (3-4 weeks per spec). Calculator becomes the scoring function for the candidate-builder's loan-to-trade ranking. Phase 6 architect kickoff should:- Re-verify the appendix in
.cursor/plans/powerfill-phase-5.plan.md("primary-source citation index for Phase 6 architect kickoff") against the legacy NVO before drafting - Apply the empirical-citation lesson: any SQL test cited as evidence must use the literal expression with production variable types
- Plan the synthetic-trades subsystem (
pfill_syn_*tables, A28) which Phase 5 explicitly deferred - Decide whether to call the C# calculator from Phase 6 in-process or to port the formula to T-SQL inside the allocation procedure (calculator stays as parity oracle either way)
- Re-verify the appendix in
- Carry-cost cache — deferred from Phase 5 §7.3. Revisit if Phase 6 profiling shows the carry curve query is hot.
- Tom/Greg residuals for Phase 9: confirm carry sign economic rationale (A34); confirm
'any'wildcard semantics; rounding-mode parity vs banker's rounding for new code; whether legacy NVO seedpfill_carry_costdefaults reflect realistic production curves.
Risks captured
- R1 —
MidpointRoundingparity (Phase 5 plan §7.5):AwayFromZerochosen to match T-SQLCAST(decimal AS NUMERIC(9,6)). Phase 9 parallel-validation surfaces any divergence; mitigation is a 1-line change. - R2 — Phase 6 may want a T-SQL calculator form: deferred decision; Phase 5 ships C# regardless because the preview endpoint and tests need it.
- R3 — PS_DemoData carry-cost rates are placeholders (A35): integration evidence proves shape only, not economic correctness; Phase 9 parallel-validation against Desktop App is the canonical correctness test.
- R4 — Carry-curve cache invalidation (deferred Phase 6 work if needed): Phase 4 CRUD endpoints would need invalidation hooks if a per-tenant cache is added.
- JSON snake_case discipline regression risk: the calculator contracts initially shipped without
[JsonPropertyName]attributes (caught by manual PoC before commit). Phase 4 contracts use snake_case attributes consistently; future contract files in PowerFill must follow the same pattern. Consider a unit test or analyzer rule that flags missing[JsonPropertyName]on PowerFill contract records.