build(agent): new-agents-2#7e3bbc iteration
This commit is contained in:
parent
5f10ae0e95
commit
dfa533d2f2
|
|
@ -0,0 +1,21 @@
|
|||
node_modules/
|
||||
.npmrc
|
||||
.env
|
||||
.env.*
|
||||
__tests__/
|
||||
coverage/
|
||||
.nyc_output/
|
||||
dist/
|
||||
build/
|
||||
.cache/
|
||||
*.log
|
||||
.DS_Store
|
||||
tmp/
|
||||
.tmp/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
READY_TO_PUBLISH
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# AGENTS.md
|
||||
|
||||
## Architecture
|
||||
|
||||
CrediMesh is a Python package that models privacy-preserving mortgage underwriting as a federated workflow.
|
||||
|
||||
Key modules:
|
||||
|
||||
- `models.py`: typed request, signal, plan, budget, and audit records.
|
||||
- `identity.py`: Ed25519-based signing and DID-style identifiers.
|
||||
- `contracts.py`: graph-of-contracts registry and adapter conformance checks.
|
||||
- `adapters.py`: income verification and property appraisal signal adapters.
|
||||
- `solver.py`: deterministic ADMM-lite underwriting solver.
|
||||
- `ledger.py`: SQLite-backed governance and audit log persistence.
|
||||
- `orchestrator.py`: end-to-end orchestration over adapters, solver, and audit trail.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Python 3.11+
|
||||
- `pydantic` for strict model validation
|
||||
- `cryptography` for signing and verification
|
||||
- `networkx` for contract graph bookkeeping
|
||||
- SQLite for governance persistence
|
||||
|
||||
## Testing
|
||||
|
||||
- `bash test.sh`
|
||||
- `pytest`
|
||||
- `python3 -m build`
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep outputs privacy-minimized; never store raw borrower payloads in audit records.
|
||||
- Preserve deterministic replay behavior for the same inputs.
|
||||
- Prefer small, explicit changes over broad rewrites.
|
||||
- Update tests when changing schemas, solver behavior, or contract rules.
|
||||
- Do not add new dependencies unless they materially improve the orchestration layer.
|
||||
74
README.md
74
README.md
|
|
@ -1,3 +1,75 @@
|
|||
# idea180-credimesh-federated-privacy
|
||||
|
||||
Source logic for Idea #180
|
||||
CrediMesh is a federated underwriting core for privacy-preserving mortgage evaluation.
|
||||
|
||||
This repository provides a deterministic orchestration slice for:
|
||||
|
||||
- privacy-minimized shared signals from income and appraisal adapters
|
||||
- graph-of-contracts registration and conformance checks
|
||||
- an ADMM-lite solver for affordability, collateral, and rate reconciliation
|
||||
- Ed25519-signed audit events
|
||||
- SQLite-backed governance persistence
|
||||
|
||||
## What it does
|
||||
|
||||
The package evaluates a `LocalUnderwritingProblem` by:
|
||||
|
||||
1. transforming borrower evidence into `SharedSignal` records with strict privacy budgets
|
||||
2. checking those signals against contract metadata in a graph registry
|
||||
3. reconciling affordability and collateral constraints with a deterministic solver
|
||||
4. writing tamper-evident audit entries into SQLite without storing raw borrower payloads
|
||||
|
||||
## Modules
|
||||
|
||||
- `models.py`: request, signal, plan, budget, and audit models
|
||||
- `identity.py`: Ed25519 DID-style identities
|
||||
- `contracts.py`: contract specs, conformance, and registry graph
|
||||
- `adapters.py`: income verification and property appraisal adapters
|
||||
- `solver.py`: deterministic underwriting solver
|
||||
- `ledger.py`: SQLite governance ledger
|
||||
- `orchestrator.py`: end-to-end flow
|
||||
|
||||
## Quick start
|
||||
|
||||
```python
|
||||
from idea180_credimesh_federated_privacy import CrediMeshOrchestrator, LocalUnderwritingProblem, SQLiteGovernanceLedger, create_identity
|
||||
|
||||
problem = LocalUnderwritingProblem(
|
||||
borrower_id="borrower-1",
|
||||
lender_id="lender-a",
|
||||
requested_amount=400000,
|
||||
property_value=520000,
|
||||
annual_income=180000,
|
||||
monthly_obligations=900,
|
||||
)
|
||||
|
||||
with SQLiteGovernanceLedger(":memory:") as ledger:
|
||||
orchestrator = CrediMeshOrchestrator(create_identity("lender-a"), ledger)
|
||||
result = orchestrator.evaluate(
|
||||
problem,
|
||||
[
|
||||
{"gross_monthly_income": 15000, "employment_months": 48, "employer_stability_score": 9.0},
|
||||
{"gross_monthly_income": 15200, "employment_months": 50, "employer_stability_score": 8.8},
|
||||
],
|
||||
{"appraised_value": 535000, "confidence": 0.9, "comparables_count": 6, "days_on_market": 18},
|
||||
)
|
||||
|
||||
print(result.plan.model_dump())
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the local verification gate:
|
||||
|
||||
```bash
|
||||
bash test.sh
|
||||
```
|
||||
|
||||
That script installs dependencies, runs `pytest`, and executes `python3 -m build`.
|
||||
|
||||
## Design rules
|
||||
|
||||
- Keep borrower data minimized at the signal boundary.
|
||||
- Preserve deterministic replay for identical inputs.
|
||||
- Update tests whenever contract behavior changes.
|
||||
- Keep the SQLite ledger append-only and privacy-safe.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "idea180-credimesh-federated-privacy"
|
||||
version = "0.1.0"
|
||||
description = "CrediMesh: federated, privacy-preserving mortgage underwriting orchestration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"cryptography>=42",
|
||||
"networkx>=3.2",
|
||||
"pydantic>=2.7",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest>=8"]
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"""CrediMesh federated underwriting orchestration package."""
|
||||
|
||||
from .adapters import IncomeVerificationAdapter, PropertyAppraisalAdapter
|
||||
from .contracts import AdapterContractSpec, ConformanceHarness, GraphOfContractsRegistry
|
||||
from .identity import Identity, create_identity
|
||||
from .ledger import SQLiteGovernanceLedger
|
||||
from .models import (
|
||||
AuditLogEntry,
|
||||
Decision,
|
||||
DualVariables,
|
||||
LocalUnderwritingProblem,
|
||||
PlanDelta,
|
||||
PolicyBlock,
|
||||
PrivacyBudget,
|
||||
SharedSignal,
|
||||
)
|
||||
from .orchestrator import CrediMeshOrchestrator, UnderwritingResult
|
||||
from .solver import ADMMLiteSolver
|
||||
|
||||
__all__ = [
|
||||
"ADMMLiteSolver",
|
||||
"AdapterContractSpec",
|
||||
"AuditLogEntry",
|
||||
"ConformanceHarness",
|
||||
"CrediMeshOrchestrator",
|
||||
"Decision",
|
||||
"DualVariables",
|
||||
"GraphOfContractsRegistry",
|
||||
"Identity",
|
||||
"IncomeVerificationAdapter",
|
||||
"LocalUnderwritingProblem",
|
||||
"PlanDelta",
|
||||
"PolicyBlock",
|
||||
"PrivacyBudget",
|
||||
"PropertyAppraisalAdapter",
|
||||
"SQLiteGovernanceLedger",
|
||||
"SharedSignal",
|
||||
"UnderwritingResult",
|
||||
"create_identity",
|
||||
]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from . import * # noqa: F401,F403
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from statistics import median
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .contracts import AdapterContractSpec
|
||||
from .models import PrivacyBudget, SharedSignal
|
||||
|
||||
|
||||
def _trimmed_mean(values: list[float]) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
ordered = sorted(values)
|
||||
if len(ordered) < 3:
|
||||
return sum(ordered) / len(ordered)
|
||||
middle = ordered[1:-1]
|
||||
return sum(middle) / len(middle)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IncomeVerificationAdapter:
|
||||
version: str = "1.0"
|
||||
|
||||
@property
|
||||
def contract(self) -> AdapterContractSpec:
|
||||
return AdapterContractSpec(
|
||||
adapter_name="income_verification",
|
||||
version=self.version,
|
||||
input_fields=("gross_monthly_income", "employment_months", "employer_stability_score"),
|
||||
output_fields=("verified_monthly_income", "employment_stability", "income_confidence"),
|
||||
privacy_budget_bytes=1024,
|
||||
description="Summarizes employment and income data into a privacy-minimized income signal.",
|
||||
)
|
||||
|
||||
def run(self, subject_id: str, records: Iterable[dict[str, Any]], budget: PrivacyBudget) -> SharedSignal:
|
||||
records = list(records)
|
||||
incomes = [float(record["gross_monthly_income"]) for record in records]
|
||||
employment_months = [float(record["employment_months"]) for record in records]
|
||||
stability = [float(record["employer_stability_score"]) for record in records]
|
||||
|
||||
payload = {
|
||||
"verified_monthly_income": round(_trimmed_mean(incomes), 2),
|
||||
"employment_stability": round(median(employment_months), 2),
|
||||
"income_confidence": round(min(0.99, max(0.2, sum(stability) / (len(stability) * 10.0))), 3),
|
||||
}
|
||||
signal = SharedSignal(
|
||||
signal_type="income_verification",
|
||||
source="income-verify-adapter",
|
||||
subject_id=subject_id,
|
||||
version=self.version,
|
||||
payload=payload,
|
||||
privacy_budget=budget.consume(payload),
|
||||
)
|
||||
return signal.with_provenance()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PropertyAppraisalAdapter:
|
||||
version: str = "1.0"
|
||||
|
||||
@property
|
||||
def contract(self) -> AdapterContractSpec:
|
||||
return AdapterContractSpec(
|
||||
adapter_name="property_appraisal",
|
||||
version=self.version,
|
||||
input_fields=("appraised_value", "confidence", "comparables_count", "days_on_market"),
|
||||
output_fields=("estimated_property_value", "appraisal_confidence", "liquidity_adjustment"),
|
||||
privacy_budget_bytes=1024,
|
||||
description="Summarizes appraisal evidence into a privacy-minimized collateral signal.",
|
||||
)
|
||||
|
||||
def run(self, subject_id: str, record: dict[str, Any], budget: PrivacyBudget) -> SharedSignal:
|
||||
comparable_factor = min(1.0, max(0.5, float(record["comparables_count"]) / 8.0))
|
||||
market_factor = 1.0 - min(0.15, float(record["days_on_market"]) / 3650.0)
|
||||
payload = {
|
||||
"estimated_property_value": round(float(record["appraised_value"]) * comparable_factor, 2),
|
||||
"appraisal_confidence": round(float(record["confidence"]), 3),
|
||||
"liquidity_adjustment": round(market_factor, 3),
|
||||
}
|
||||
signal = SharedSignal(
|
||||
signal_type="property_appraisal",
|
||||
source="appraisal-adapter",
|
||||
subject_id=subject_id,
|
||||
version=self.version,
|
||||
payload=payload,
|
||||
privacy_budget=budget.consume(payload),
|
||||
)
|
||||
return signal.with_provenance()
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
import networkx as nx
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from .models import SharedSignal
|
||||
from .utils import sha256_hex
|
||||
|
||||
|
||||
class AdapterContractSpec(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
adapter_name: str
|
||||
version: str
|
||||
input_fields: tuple[str, ...] = Field(default_factory=tuple)
|
||||
output_fields: tuple[str, ...] = Field(default_factory=tuple)
|
||||
privacy_budget_bytes: int = Field(gt=0)
|
||||
description: str = ""
|
||||
|
||||
@field_validator("input_fields", "output_fields")
|
||||
@classmethod
|
||||
def _dedupe(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(value))
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.adapter_name}@{self.version}"
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
return sha256_hex(self.model_dump())
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
accepted: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class ConformanceHarness:
|
||||
def validate(self, spec: AdapterContractSpec, signal: SharedSignal) -> ValidationResult:
|
||||
if signal.signal_type != spec.adapter_name:
|
||||
return ValidationResult(False, "signal type does not match adapter")
|
||||
keys = set(signal.payload)
|
||||
allowed = set(spec.output_fields)
|
||||
if keys != allowed:
|
||||
return ValidationResult(False, "signal payload contains unsupported fields")
|
||||
return ValidationResult(True)
|
||||
|
||||
|
||||
class GraphOfContractsRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._graph = nx.DiGraph()
|
||||
self._specs: dict[str, AdapterContractSpec] = {}
|
||||
|
||||
def register(self, spec: AdapterContractSpec) -> None:
|
||||
self._specs[spec.key] = spec
|
||||
self._graph.add_node(spec.key, spec=spec.model_dump())
|
||||
|
||||
def connect(self, upstream: str, downstream: str) -> None:
|
||||
if upstream not in self._specs or downstream not in self._specs:
|
||||
raise KeyError("unknown contract key")
|
||||
self._graph.add_edge(upstream, downstream)
|
||||
|
||||
def get(self, key: str) -> AdapterContractSpec:
|
||||
return self._specs[key]
|
||||
|
||||
def list_versions(self, adapter_name: str) -> list[str]:
|
||||
return sorted(spec.version for spec in self._specs.values() if spec.adapter_name == adapter_name)
|
||||
|
||||
def digest(self) -> str:
|
||||
nodes = sorted(self._graph.nodes)
|
||||
edges = sorted([list(edge) for edge in self._graph.edges])
|
||||
return sha256_hex({"nodes": nodes, "edges": edges})
|
||||
|
||||
def conformance_chain(self, adapter_keys: Iterable[str]) -> list[AdapterContractSpec]:
|
||||
return [self._specs[key] for key in adapter_keys]
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
from .utils import canonical_json, sha256_hex
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Identity:
|
||||
did: str
|
||||
private_key: ed25519.Ed25519PrivateKey
|
||||
|
||||
@property
|
||||
def public_key(self) -> ed25519.Ed25519PublicKey:
|
||||
return self.private_key.public_key()
|
||||
|
||||
@property
|
||||
def public_key_fingerprint(self) -> str:
|
||||
raw = self.public_key.public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
return sha256_hex(raw.hex())
|
||||
|
||||
def sign(self, message: bytes) -> bytes:
|
||||
return self.private_key.sign(message)
|
||||
|
||||
def verify(self, message: bytes, signature: bytes) -> None:
|
||||
self.public_key.verify(signature, message)
|
||||
|
||||
def verify_hex(self, message: bytes, signature_hex: str) -> None:
|
||||
self.verify(message, bytes.fromhex(signature_hex))
|
||||
|
||||
def verify_audit_entry(self, entry: Any) -> None:
|
||||
from .models import AuditLogEntry as _AuditLogEntry
|
||||
|
||||
if not isinstance(entry, _AuditLogEntry):
|
||||
raise TypeError("expected AuditLogEntry")
|
||||
self.verify_hex(canonical_json(entry.signing_payload()).encode("utf-8"), entry.signature)
|
||||
|
||||
|
||||
def create_identity(name: str) -> Identity:
|
||||
private_key = ed25519.Ed25519PrivateKey.generate()
|
||||
fingerprint = sha256_hex(private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
))[:24]
|
||||
return Identity(did=f"did:credimesh:{name}:{fingerprint}", private_key=private_key)
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from .models import AuditLogEntry
|
||||
|
||||
|
||||
class SQLiteGovernanceLedger:
|
||||
def __init__(self, path: str | Path = ":memory:") -> None:
|
||||
self.path = str(path)
|
||||
self._conn = sqlite3.connect(self.path)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self) -> None:
|
||||
self._conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
sequence INTEGER PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
payload_hash TEXT NOT NULL,
|
||||
signer_id TEXT NOT NULL,
|
||||
signature TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS contract_registry (
|
||||
contract_key TEXT PRIMARY KEY,
|
||||
payload TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def append(self, entry: AuditLogEntry) -> int:
|
||||
self._conn.execute(
|
||||
"INSERT INTO audit_log(sequence, timestamp, event_type, subject_id, payload_hash, signer_id, signature) VALUES(?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
entry.sequence,
|
||||
entry.timestamp.isoformat(),
|
||||
entry.event_type,
|
||||
entry.subject_id,
|
||||
entry.payload_hash,
|
||||
entry.signer_id,
|
||||
entry.signature,
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
return entry.sequence
|
||||
|
||||
def list_audit_entries(self) -> list[AuditLogEntry]:
|
||||
rows = self._conn.execute("SELECT * FROM audit_log ORDER BY sequence ASC").fetchall()
|
||||
return [
|
||||
AuditLogEntry(
|
||||
sequence=row["sequence"],
|
||||
timestamp=row["timestamp"],
|
||||
event_type=row["event_type"],
|
||||
subject_id=row["subject_id"],
|
||||
payload_hash=row["payload_hash"],
|
||||
signer_id=row["signer_id"],
|
||||
signature=row["signature"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def store_contract(self, contract_key: str, payload: dict) -> None:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO contract_registry(contract_key, payload) VALUES(?, ?)",
|
||||
(contract_key, json.dumps(payload, sort_keys=True, separators=(",", ":"))),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def list_contracts(self) -> dict[str, dict]:
|
||||
rows = self._conn.execute("SELECT * FROM contract_registry ORDER BY contract_key ASC").fetchall()
|
||||
return {row["contract_key"]: json.loads(row["payload"]) for row in rows}
|
||||
|
||||
def close(self) -> None:
|
||||
self._conn.close()
|
||||
|
||||
def __enter__(self) -> "SQLiteGovernanceLedger":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None:
|
||||
self.close()
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from .utils import canonical_json, payload_size, sha256_hex
|
||||
|
||||
|
||||
class Decision(str, Enum):
|
||||
approved = "approved"
|
||||
declined = "declined"
|
||||
review = "review"
|
||||
|
||||
|
||||
class PrivacyBudget(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
max_bytes: int = Field(gt=0)
|
||||
max_fields: int = Field(gt=0)
|
||||
consumed_bytes: int = Field(default=0, ge=0)
|
||||
consumed_fields: int = Field(default=0, ge=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_bounds(self) -> "PrivacyBudget":
|
||||
if self.consumed_bytes > self.max_bytes:
|
||||
raise ValueError("consumed_bytes exceeds budget")
|
||||
if self.consumed_fields > self.max_fields:
|
||||
raise ValueError("consumed_fields exceeds budget")
|
||||
return self
|
||||
|
||||
def can_consume(self, payload: dict[str, Any]) -> bool:
|
||||
return self.consumed_bytes + payload_size(payload) <= self.max_bytes and self.consumed_fields + len(payload) <= self.max_fields
|
||||
|
||||
def consume(self, payload: dict[str, Any]) -> "PrivacyBudget":
|
||||
bytes_used = payload_size(payload)
|
||||
fields_used = len(payload)
|
||||
if self.consumed_bytes + bytes_used > self.max_bytes:
|
||||
raise ValueError("payload exceeds max_bytes")
|
||||
if self.consumed_fields + fields_used > self.max_fields:
|
||||
raise ValueError("payload exceeds max_fields")
|
||||
return self.model_copy(
|
||||
update={
|
||||
"consumed_bytes": self.consumed_bytes + bytes_used,
|
||||
"consumed_fields": self.consumed_fields + fields_used,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PolicyBlock(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
allowed_fields: tuple[str, ...] = Field(default_factory=tuple)
|
||||
min_adapter_version: str = "1.0"
|
||||
max_records_per_source: int = Field(default=32, gt=0)
|
||||
|
||||
@field_validator("allowed_fields")
|
||||
@classmethod
|
||||
def _normalize_fields(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(value))
|
||||
|
||||
|
||||
class LocalUnderwritingProblem(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
borrower_id: str
|
||||
lender_id: str
|
||||
requested_amount: float = Field(gt=0)
|
||||
property_value: float = Field(gt=0)
|
||||
annual_income: float = Field(gt=0)
|
||||
monthly_obligations: float = Field(ge=0)
|
||||
term_months: int = Field(default=360, gt=0)
|
||||
max_dti: float = Field(default=0.43, gt=0, le=1)
|
||||
max_ltv: float = Field(default=0.8, gt=0, le=1)
|
||||
base_interest_rate: float = Field(default=0.06, gt=0, le=1)
|
||||
policy: PolicyBlock = Field(default_factory=PolicyBlock)
|
||||
privacy_budget: PrivacyBudget = Field(default_factory=lambda: PrivacyBudget(max_bytes=4096, max_fields=128))
|
||||
|
||||
@property
|
||||
def monthly_income(self) -> float:
|
||||
return self.annual_income / 12.0
|
||||
|
||||
|
||||
class SharedSignal(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
signal_type: str
|
||||
source: str
|
||||
subject_id: str
|
||||
version: str = "1.0"
|
||||
payload: dict[str, Any]
|
||||
privacy_budget: PrivacyBudget
|
||||
provenance_hash: str = Field(default="")
|
||||
signature: str = Field(default="")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_budget(self) -> "SharedSignal":
|
||||
if self.privacy_budget.consumed_bytes > self.privacy_budget.max_bytes:
|
||||
raise ValueError("payload exceeds privacy budget")
|
||||
if self.privacy_budget.consumed_fields > self.privacy_budget.max_fields:
|
||||
raise ValueError("payload exceeds privacy budget")
|
||||
return self
|
||||
|
||||
def with_provenance(self) -> "SharedSignal":
|
||||
provenance_hash = sha256_hex({"signal_type": self.signal_type, "source": self.source, "subject_id": self.subject_id, "version": self.version, "payload": self.payload})
|
||||
return self.model_copy(update={"provenance_hash": provenance_hash})
|
||||
|
||||
def minimal_payload(self) -> dict[str, Any]:
|
||||
return dict(self.payload)
|
||||
|
||||
|
||||
class DualVariables(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
risk_multiplier: float = Field(default=1.0, ge=0)
|
||||
affordability_gap: float = Field(default=0.0)
|
||||
collateral_gap: float = Field(default=0.0)
|
||||
|
||||
|
||||
class PlanDelta(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
decision: Decision
|
||||
loan_amount: float = Field(ge=0)
|
||||
annual_rate: float = Field(gt=0, le=1)
|
||||
term_months: int = Field(gt=0)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AuditLogEntry(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
sequence: int = Field(ge=0)
|
||||
timestamp: datetime
|
||||
event_type: str
|
||||
subject_id: str
|
||||
payload_hash: str
|
||||
signer_id: str
|
||||
signature: str
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
sequence: int,
|
||||
event_type: str,
|
||||
subject_id: str,
|
||||
payload: dict[str, Any],
|
||||
signer_id: str,
|
||||
signature: str,
|
||||
timestamp: datetime | None = None,
|
||||
) -> "AuditLogEntry":
|
||||
ts = timestamp or datetime.now(timezone.utc)
|
||||
return cls(
|
||||
sequence=sequence,
|
||||
timestamp=ts,
|
||||
event_type=event_type,
|
||||
subject_id=subject_id,
|
||||
payload_hash=sha256_hex(payload),
|
||||
signer_id=signer_id,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
def signing_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"sequence": self.sequence,
|
||||
"timestamp": self.timestamp.isoformat(),
|
||||
"event_type": self.event_type,
|
||||
"subject_id": self.subject_id,
|
||||
"payload_hash": self.payload_hash,
|
||||
"signer_id": self.signer_id,
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Iterable
|
||||
|
||||
from .adapters import IncomeVerificationAdapter, PropertyAppraisalAdapter
|
||||
from .contracts import AdapterContractSpec, ConformanceHarness, GraphOfContractsRegistry
|
||||
from .identity import Identity
|
||||
from .ledger import SQLiteGovernanceLedger
|
||||
from .models import AuditLogEntry, LocalUnderwritingProblem, PlanDelta, SharedSignal
|
||||
from .solver import ADMMLiteSolver, SolverResult
|
||||
from .utils import canonical_json, sha256_hex
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnderwritingResult:
|
||||
problem: LocalUnderwritingProblem
|
||||
plan: PlanDelta
|
||||
signals: list[SharedSignal]
|
||||
solver: SolverResult
|
||||
replay_token: str
|
||||
|
||||
|
||||
class CrediMeshOrchestrator:
|
||||
def __init__(
|
||||
self,
|
||||
identity: Identity,
|
||||
ledger: SQLiteGovernanceLedger,
|
||||
registry: GraphOfContractsRegistry | None = None,
|
||||
harness: ConformanceHarness | None = None,
|
||||
solver: ADMMLiteSolver | None = None,
|
||||
) -> None:
|
||||
self.identity = identity
|
||||
self.ledger = ledger
|
||||
self.registry = registry or GraphOfContractsRegistry()
|
||||
self.harness = harness or ConformanceHarness()
|
||||
self.solver = solver or ADMMLiteSolver()
|
||||
self.income_adapter = IncomeVerificationAdapter()
|
||||
self.appraisal_adapter = PropertyAppraisalAdapter()
|
||||
self.registry.register(self.income_adapter.contract)
|
||||
self.registry.register(self.appraisal_adapter.contract)
|
||||
self.registry.connect(self.income_adapter.contract.key, self.appraisal_adapter.contract.key)
|
||||
self.ledger.store_contract(self.income_adapter.contract.key, self.income_adapter.contract.model_dump())
|
||||
self.ledger.store_contract(self.appraisal_adapter.contract.key, self.appraisal_adapter.contract.model_dump())
|
||||
|
||||
def _append_event(self, event_type: str, subject_id: str, payload: dict) -> AuditLogEntry:
|
||||
sequence = len(self.ledger.list_audit_entries())
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
signing_payload = {
|
||||
"sequence": sequence,
|
||||
"timestamp": timestamp.isoformat(),
|
||||
"event_type": event_type,
|
||||
"subject_id": subject_id,
|
||||
"payload_hash": sha256_hex(payload),
|
||||
"signer_id": self.identity.did,
|
||||
}
|
||||
signature = self.identity.sign(canonical_json(signing_payload).encode("utf-8")).hex()
|
||||
entry = AuditLogEntry.create(
|
||||
sequence=sequence,
|
||||
event_type=event_type,
|
||||
subject_id=subject_id,
|
||||
payload=payload,
|
||||
signer_id=self.identity.did,
|
||||
signature=signature,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
self.ledger.append(entry)
|
||||
return entry
|
||||
|
||||
def evaluate(
|
||||
self,
|
||||
problem: LocalUnderwritingProblem,
|
||||
income_records: Iterable[dict],
|
||||
appraisal_record: dict,
|
||||
) -> UnderwritingResult:
|
||||
income_signal = self.income_adapter.run(problem.borrower_id, income_records, problem.privacy_budget)
|
||||
appraisal_signal = self.appraisal_adapter.run(problem.borrower_id, appraisal_record, income_signal.privacy_budget)
|
||||
|
||||
for spec, signal in (
|
||||
(self.income_adapter.contract, income_signal),
|
||||
(self.appraisal_adapter.contract, appraisal_signal),
|
||||
):
|
||||
result = self.harness.validate(spec, signal)
|
||||
if not result.accepted:
|
||||
raise ValueError(result.reason)
|
||||
|
||||
solver_result = self.solver.solve(problem, [income_signal, appraisal_signal])
|
||||
|
||||
event_payload = {
|
||||
"problem": problem.model_dump(),
|
||||
"signals": [signal.model_dump() for signal in (income_signal, appraisal_signal)],
|
||||
"plan": solver_result.plan.model_dump(),
|
||||
"dual_variables": solver_result.dual_variables.model_dump(),
|
||||
}
|
||||
self._append_event("underwriting.decision", problem.borrower_id, event_payload)
|
||||
|
||||
replay_token = sha256_hex(event_payload)
|
||||
return UnderwritingResult(
|
||||
problem=problem,
|
||||
plan=solver_result.plan,
|
||||
signals=[income_signal, appraisal_signal],
|
||||
solver=solver_result,
|
||||
replay_token=replay_token,
|
||||
)
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .models import Decision, DualVariables, LocalUnderwritingProblem, PlanDelta, SharedSignal
|
||||
|
||||
|
||||
def _monthly_payment(principal: float, annual_rate: float, months: int) -> float:
|
||||
if principal <= 0:
|
||||
return 0.0
|
||||
monthly_rate = annual_rate / 12.0
|
||||
if monthly_rate == 0:
|
||||
return principal / months
|
||||
factor = (1 + monthly_rate) ** months
|
||||
return principal * monthly_rate * factor / (factor - 1)
|
||||
|
||||
|
||||
def _max_principal(payment: float, annual_rate: float, months: int) -> float:
|
||||
if payment <= 0:
|
||||
return 0.0
|
||||
monthly_rate = annual_rate / 12.0
|
||||
if monthly_rate == 0:
|
||||
return payment * months
|
||||
factor = (1 + monthly_rate) ** months
|
||||
return payment * (factor - 1) / (monthly_rate * factor)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SolverTraceStep:
|
||||
iteration: int
|
||||
candidate_amount: float
|
||||
affordability_cap: float
|
||||
collateral_cap: float
|
||||
dual: float
|
||||
residual: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class SolverResult:
|
||||
plan: PlanDelta
|
||||
dual_variables: DualVariables
|
||||
trace: list[SolverTraceStep]
|
||||
|
||||
|
||||
class ADMMLiteSolver:
|
||||
def __init__(self, rho: float = 0.35, max_iterations: int = 12, tolerance: float = 1e-3) -> None:
|
||||
self.rho = rho
|
||||
self.max_iterations = max_iterations
|
||||
self.tolerance = tolerance
|
||||
|
||||
def solve(self, problem: LocalUnderwritingProblem, signals: list[SharedSignal]) -> SolverResult:
|
||||
signal_map = {signal.signal_type: signal.payload for signal in signals}
|
||||
income_signal = signal_map.get("income_verification", {})
|
||||
appraisal_signal = signal_map.get("property_appraisal", {})
|
||||
|
||||
verified_income = float(income_signal.get("verified_monthly_income", problem.monthly_income))
|
||||
confidence = float(income_signal.get("income_confidence", 0.5))
|
||||
property_value = float(appraisal_signal.get("estimated_property_value", problem.property_value))
|
||||
liquidity = float(appraisal_signal.get("liquidity_adjustment", 1.0))
|
||||
appraisal_confidence = float(appraisal_signal.get("appraisal_confidence", 0.5))
|
||||
|
||||
borrower_payment_cap = max(0.0, verified_income * problem.max_dti - problem.monthly_obligations)
|
||||
collateral_cap = problem.property_value * problem.max_ltv * max(0.75, liquidity) * max(0.5, appraisal_confidence)
|
||||
risk_premium = max(0.0, 0.025 * (1.0 - confidence) + 0.02 * (1.0 - appraisal_confidence))
|
||||
annual_rate = min(0.095, max(problem.base_interest_rate, problem.base_interest_rate + risk_premium))
|
||||
|
||||
candidate_amount = min(problem.requested_amount, collateral_cap)
|
||||
dual = 0.0
|
||||
trace: list[SolverTraceStep] = []
|
||||
|
||||
for iteration in range(1, self.max_iterations + 1):
|
||||
affordability_cap = _max_principal(borrower_payment_cap, annual_rate, problem.term_months)
|
||||
target = min(problem.requested_amount, affordability_cap, collateral_cap)
|
||||
residual = target - candidate_amount
|
||||
dual = dual + self.rho * residual
|
||||
candidate_amount = max(0.0, min(target, candidate_amount + dual * 0.15))
|
||||
trace.append(
|
||||
SolverTraceStep(
|
||||
iteration=iteration,
|
||||
candidate_amount=round(candidate_amount, 2),
|
||||
affordability_cap=round(affordability_cap, 2),
|
||||
collateral_cap=round(collateral_cap, 2),
|
||||
dual=round(dual, 4),
|
||||
residual=round(residual, 4),
|
||||
)
|
||||
)
|
||||
if abs(residual) < self.tolerance:
|
||||
break
|
||||
|
||||
final_payment = _monthly_payment(candidate_amount, annual_rate, problem.term_months)
|
||||
if candidate_amount <= 0 or final_payment > borrower_payment_cap * 1.02:
|
||||
decision = Decision.declined
|
||||
elif candidate_amount < problem.requested_amount * 0.8:
|
||||
decision = Decision.review
|
||||
else:
|
||||
decision = Decision.approved
|
||||
|
||||
plan = PlanDelta(
|
||||
decision=decision,
|
||||
loan_amount=round(candidate_amount, 2),
|
||||
annual_rate=round(annual_rate, 4),
|
||||
term_months=problem.term_months,
|
||||
metadata={
|
||||
"monthly_payment": round(final_payment, 2),
|
||||
"borrower_payment_cap": round(borrower_payment_cap, 2),
|
||||
"property_value_used": round(property_value, 2),
|
||||
},
|
||||
)
|
||||
dual_variables = DualVariables(
|
||||
risk_multiplier=round(1.0 + max(0.0, dual), 4),
|
||||
affordability_gap=round(borrower_payment_cap - final_payment, 2),
|
||||
collateral_gap=round(collateral_cap - candidate_amount, 2),
|
||||
)
|
||||
return SolverResult(plan=plan, dual_variables=dual_variables, trace=trace)
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, default=str)
|
||||
|
||||
|
||||
def sha256_hex(value: Any) -> str:
|
||||
if isinstance(value, (bytes, bytearray, memoryview)):
|
||||
payload = bytes(value)
|
||||
else:
|
||||
payload = canonical_json(value).encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def payload_size(value: Mapping[str, Any]) -> int:
|
||||
return len(canonical_json(value).encode("utf-8"))
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
python3 -m pip install -e ".[test]" build
|
||||
pytest
|
||||
python3 -m build
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from idea180_credimesh_federated_privacy import (
|
||||
CrediMeshOrchestrator,
|
||||
GraphOfContractsRegistry,
|
||||
Identity,
|
||||
LocalUnderwritingProblem,
|
||||
SQLiteGovernanceLedger,
|
||||
create_identity,
|
||||
)
|
||||
from idea180_credimesh_federated_privacy.adapters import IncomeVerificationAdapter, PropertyAppraisalAdapter
|
||||
from idea180_credimesh_federated_privacy.contracts import ConformanceHarness
|
||||
from idea180_credimesh_federated_privacy.models import PrivacyBudget
|
||||
|
||||
|
||||
def test_contract_registry_tracks_versions_and_digest() -> None:
|
||||
registry = GraphOfContractsRegistry()
|
||||
income = IncomeVerificationAdapter().contract
|
||||
appraisal = PropertyAppraisalAdapter().contract
|
||||
registry.register(income)
|
||||
registry.register(appraisal)
|
||||
registry.connect(income.key, appraisal.key)
|
||||
|
||||
assert registry.list_versions("income_verification") == ["1.0"]
|
||||
assert registry.digest()
|
||||
|
||||
|
||||
def test_adapters_emit_privacy_minimized_signals() -> None:
|
||||
adapter = IncomeVerificationAdapter()
|
||||
budget = PrivacyBudget(max_bytes=4096, max_fields=32)
|
||||
signal = adapter.run(
|
||||
"borrower-1",
|
||||
[
|
||||
{"gross_monthly_income": 8500, "employment_months": 36, "employer_stability_score": 8.5},
|
||||
{"gross_monthly_income": 9100, "employment_months": 42, "employer_stability_score": 9.0},
|
||||
{"gross_monthly_income": 8900, "employment_months": 40, "employer_stability_score": 8.8},
|
||||
],
|
||||
budget,
|
||||
)
|
||||
|
||||
assert set(signal.payload) == {"verified_monthly_income", "employment_stability", "income_confidence"}
|
||||
assert signal.provenance_hash
|
||||
|
||||
|
||||
def test_orchestrator_produces_deterministic_plan_and_audit_log(tmp_path: Path) -> None:
|
||||
ledger_path = tmp_path / "ledger.sqlite3"
|
||||
identity = create_identity("lender-a")
|
||||
problem = LocalUnderwritingProblem(
|
||||
borrower_id="borrower-99",
|
||||
lender_id="lender-a",
|
||||
requested_amount=420000,
|
||||
property_value=540000,
|
||||
annual_income=186000,
|
||||
monthly_obligations=750,
|
||||
)
|
||||
|
||||
with SQLiteGovernanceLedger(ledger_path) as ledger:
|
||||
orchestrator = CrediMeshOrchestrator(identity=identity, ledger=ledger, harness=ConformanceHarness())
|
||||
result_one = orchestrator.evaluate(
|
||||
problem,
|
||||
[
|
||||
{"gross_monthly_income": 15400, "employment_months": 48, "employer_stability_score": 9.0},
|
||||
{"gross_monthly_income": 15600, "employment_months": 51, "employer_stability_score": 8.9},
|
||||
{"gross_monthly_income": 15500, "employment_months": 50, "employer_stability_score": 9.1},
|
||||
],
|
||||
{"appraised_value": 560000, "confidence": 0.88, "comparables_count": 7, "days_on_market": 22},
|
||||
)
|
||||
result_two = orchestrator.evaluate(
|
||||
problem,
|
||||
[
|
||||
{"gross_monthly_income": 15400, "employment_months": 48, "employer_stability_score": 9.0},
|
||||
{"gross_monthly_income": 15600, "employment_months": 51, "employer_stability_score": 8.9},
|
||||
{"gross_monthly_income": 15500, "employment_months": 50, "employer_stability_score": 9.1},
|
||||
],
|
||||
{"appraised_value": 560000, "confidence": 0.88, "comparables_count": 7, "days_on_market": 22},
|
||||
)
|
||||
|
||||
assert result_one.plan.loan_amount == result_two.plan.loan_amount
|
||||
assert result_one.replay_token == result_two.replay_token
|
||||
audit_entries = ledger.list_audit_entries()
|
||||
assert len(audit_entries) == 2
|
||||
assert audit_entries[0].signature
|
||||
identity.verify_audit_entry(audit_entries[0])
|
||||
|
||||
|
||||
def test_ledger_persists_contracts_and_entries(tmp_path: Path) -> None:
|
||||
ledger_path = tmp_path / "ledger.sqlite3"
|
||||
with SQLiteGovernanceLedger(ledger_path) as ledger:
|
||||
ledger.store_contract("example@1.0", {"foo": "bar"})
|
||||
assert ledger.list_contracts()["example@1.0"]["foo"] == "bar"
|
||||
Loading…
Reference in New Issue