open-standards/openpay/crates/op-graph/tests/discrepancy_routing.rs
Transaction Science 70ecd6caa0 Initial import — three open standards under one shelf
Transaction Science open standards monorepo. Each subdirectory is a
self-contained reference protocol with its own README and licence.

- openpay/    Apache-2.0  payment-acceptance stack (Rust, 26 crates)
- smart-byte/ CC-BY-4.0   content-addressed value carrier (spec)
- eoc/        CC-BY-4.0   energy-optimised compute (spec)

The wire format and the right to fork are public. The optional hosted
services are the offer; the protocols are not.
2026-05-23 07:58:09 -04:00

202 lines
7.1 KiB
Rust

//! Phase 19: reconciliation-density routing signal.
//!
//! Closes a longer loop than Phase 18:
//!
//! ```text
//! Orchestrator records rail_attempt[external_id_hint = idempotency_key]
//! ↓
//! Operator posts a ledger_tx with the same external_id
//! ↓
//! Reconciliation finds a discrepancy on that tx and records a task
//! ↓
//! Router queries `discrepancy_score(rail, driver)` → 1 task /
//! 1 attempt = 1.0 — pushes the driver back even though every
//! HTTP attempt against that driver returned OK.
//! ```
//!
//! This is the "noisy reconciliation" case Phase 18 explicitly
//! deferred and Phase 19 ships.
use std::sync::Arc;
use op_core::{Currency, Money, PaymentMethod, RailKind, VaultRef};
use op_ledger::{Account, AccountClass, Entry, Ledger, LedgerStore, Transaction, TransactionId};
use op_orchestrator::engine::{AdapterResult, RailAdapter};
use op_orchestrator::{
AttemptOutcome, IdempotencyKey, Orchestrator, PaymentIntent, PolicyRouter, RailTelemetry,
RoutingSignals,
};
use op_reconciliation::{Discrepancy, ReconciliationReport, ReconciliationStore};
use op_graph::{GraphHandle, GraphLedgerStore, GraphRailTelemetry, GraphReconciliationStore};
const DRIVER_DIRTY: &str = "dirty_psp";
const DRIVER_CLEAN: &str = "clean_psp";
#[derive(Clone)]
struct AlwaysApproveAdapter {
driver: &'static str,
}
impl RailAdapter for AlwaysApproveAdapter {
fn rail(&self) -> RailKind {
RailKind::Card
}
fn driver(&self) -> &str {
self.driver
}
fn attempt(&self, _: &PaymentIntent, _: usize) -> AdapterResult {
AdapterResult {
outcome: AttemptOutcome::Success,
psp_payment_id: Some(format!("{}_psp_id", self.driver)),
uetr: None,
}
}
}
fn vault_intent(key: &str) -> PaymentIntent {
PaymentIntent::new(
IdempotencyKey::new(key),
Money::from_minor(5_000, Currency::USD),
PaymentMethod::Vault(VaultRef::new("tok_v7_test")),
)
}
/// Operator-side helper: after an attempt succeeded on `(rail,
/// driver)` for `intent_key`, post a balanced ledger transaction
/// keyed on the same idempotency key. This is the join key
/// `discrepancy_score` walks.
fn post_ledger_tx_for_intent(
ledger: &GraphLedgerStore,
lid: op_ledger::LedgerId,
cash: op_ledger::AccountId,
rev: op_ledger::AccountId,
intent_key: &str,
amount_minor: i64,
effective_at: u64,
) -> TransactionId {
let tx = Transaction::new_posted(
lid,
effective_at,
vec![
Entry::debit(cash, Money::from_minor(amount_minor, Currency::USD)),
Entry::credit(rev, Money::from_minor(amount_minor, Currency::USD)),
],
)
.unwrap()
.with_external_id(intent_key);
ledger.post_transaction(tx).unwrap()
}
#[test]
fn reconciliation_discrepancy_pushes_driver_back_even_when_http_is_clean() {
// One shared graph: telemetry, ledger, and reconciliation all
// sit in the same fact store, so the join works.
let handle = GraphHandle::new_in_memory();
let telemetry: Arc<GraphRailTelemetry> =
Arc::new(GraphRailTelemetry::with_handle(handle.clone()));
let ledger = GraphLedgerStore::with_handle(handle.clone());
let recon = GraphReconciliationStore::with_handle(handle.clone());
// Standard chart of accounts.
let l = Ledger::new("Acme").unwrap();
let lid = l.id;
ledger.create_ledger(l).unwrap();
let cash = Account::new(lid, "cash", AccountClass::Asset, Currency::USD);
let rev = Account::new(lid, "rev", AccountClass::Revenue, Currency::USD);
let cash_id = cash.id;
let rev_id = rev.id;
ledger.create_account(cash).unwrap();
ledger.create_account(rev).unwrap();
// The router lists `dirty_psp` FIRST. Both adapters always
// approve at the HTTP level — the only signal that can re-order
// them is the reconciliation discrepancy we'll plant.
let router = PolicyRouter::new(
vec![DRIVER_DIRTY.to_owned(), DRIVER_CLEAN.to_owned()],
vec![],
)
.with_signals(telemetry.clone() as Arc<dyn RoutingSignals>);
let mut orch = Orchestrator::new()
.with_router(Box::new(router))
.with_telemetry(telemetry.clone() as Arc<dyn RailTelemetry>);
orch.register_adapter(Arc::new(AlwaysApproveAdapter {
driver: DRIVER_DIRTY,
}));
orch.register_adapter(Arc::new(AlwaysApproveAdapter {
driver: DRIVER_CLEAN,
}));
// First intent: static order [dirty, clean]; dirty succeeds at
// HTTP on first try. One attempt, recorded against dirty.
let first = orch.run(&vault_intent("intent-1")).unwrap();
assert_eq!(first.attempts.len(), 1);
assert_eq!(first.attempts[0].driver, DRIVER_DIRTY);
// Operator posts the ledger transaction with the same
// idempotency key as its external_id. This is the natural
// production pattern; the telemetry's external_id_hint matches.
let tx_id = post_ledger_tx_for_intent(&ledger, lid, cash_id, rev_id, "intent-1", 5_000, 1_000);
let _ = (rev_id, tx_id);
// Reconciliation finds a discrepancy on that ledger tx. We
// record one task pointing at tx_id via the trait — that builds
// the task_about edge GraphRailTelemetry walks.
let report = ReconciliationReport {
window: (0, 10_000),
matched: 0,
fuzzy_matched: 0,
discrepancies: vec![Discrepancy::UnmatchedLedger {
tx_id,
external_id: Some("intent-1".into()),
}],
matched_pairs: Vec::new(),
};
recon.record_report(&report).unwrap();
// Discrepancy_score = 1 task / 1 attempt for dirty; 0 for
// clean (no attempts). With the combined-score router,
// dirty now ranks BEHIND clean.
let dirty_score = telemetry.discrepancy_score_at(RailKind::Card, DRIVER_DIRTY, 2_000);
let clean_score = telemetry.discrepancy_score_at(RailKind::Card, DRIVER_CLEAN, 2_000);
assert!(
dirty_score > 0.0,
"expected dirty score > 0, got {dirty_score}"
);
assert_eq!(clean_score, 0.0);
// Second intent: combined score pushes dirty back, clean is
// tried first and succeeds on the first attempt.
let second = orch.run(&vault_intent("intent-2")).unwrap();
assert_eq!(
second.attempts.len(),
1,
"expected one attempt on the clean driver, got {:?}",
second.attempts
);
assert_eq!(second.attempts[0].driver, DRIVER_CLEAN);
}
#[test]
fn empty_reconciliation_state_yields_zero_discrepancy_score() {
let telemetry = GraphRailTelemetry::new_in_memory();
telemetry.record_attempt(
RailKind::Card,
DRIVER_DIRTY,
op_orchestrator::AttemptResultClass::Approved,
1_000,
Some("intent-zero"),
None,
);
// No ledger_tx with that external_id; no reconciliation tasks.
let score = telemetry.discrepancy_score_at(RailKind::Card, DRIVER_DIRTY, 1_100);
assert_eq!(score, 0.0);
}
#[test]
fn driver_with_no_attempts_yields_zero_discrepancy_score() {
let telemetry = GraphRailTelemetry::new_in_memory();
// A driver we've never seen has nothing to compute against.
let score = telemetry.discrepancy_score_at(RailKind::Card, "ghost_driver", 1_000);
assert_eq!(score, 0.0);
}