From 6f96a2b60b746035a989cda9d4cade9417b7d92c Mon Sep 17 00:00:00 2001 From: Baldri Date: Mon, 11 May 2026 07:27:53 +0200 Subject: [PATCH 1/2] docs(manifesto): correct blog URL to full slug path --- manifesto/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifesto/README.md b/manifesto/README.md index 703ae93..0d007d5 100644 --- a/manifesto/README.md +++ b/manifesto/README.md @@ -101,6 +101,6 @@ MIT — because commerce infrastructure should be auditable, forkable, and free ## Further Reading -- [Protocol Commerce Manifesto](https://digital-opua.ch/blog/protocol-commerce-manifesto) — strategic perspective (German) +- [Protocol Commerce Manifesto](https://digital-opua.ch/blog/protocol-commerce--warum-commerce-offene-protokolle-braucht) — strategic perspective (German) - [Nexbid Documentation](https://nexbid.dev) — reference implementation - [AdCP Specification](https://github.com/nexbid-dev/protocol-commerce/adcp-spec) — the protocol From 206a979c47f7eb303306f62ee62c99c422603127 Mon Sep 17 00:00:00 2001 From: Baldri Date: Fri, 15 May 2026 20:01:06 +0200 Subject: [PATCH 2/2] feat(lean): import 47 Lean 4 theorems from Nexbid main repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds machine-checked formal verification of the Nexbid auction core to the protocol-commerce open-source repository. All 47 theorems are sorry-free and pass `lake build` (verified locally 2026-05-15 in both source repo and this destination). Coverage: - Score boundedness, KAN-consistency (Score, KanScore, Consistency, Normalize) - Auction eligibility + winner selection (Auction, Monotone, EndToEnd) - Atomic budget decrement safety (Budget — T7a-T7e + composition) - Wallet payment bounds + idempotency (Wallet — W3/W5/W6a + L3) - Commerce-layer revenue, policy, DSL (Commerce/{Revenue,Policy,DSL}) - Custom Rat-helper library (RatHelpers — closes Lean stdlib gaps without Mathlib) NOT covered by these proofs (per README disclaimer): authentication, RBAC, CORS, SSRF, network safety, TS-to-Lean implementation conformance. Those are enforced by code review, tests, and red-team tests in the private Nexbid main repository. The `defaultRevenueShare` constant in Commerce/DSL.lean uses a generic 70/30 library default; theorems T8-T10 are generic over any `share : RevenueShare` instance. Production Nexbid AdCP tier-pricing (90/10 Standard, 95/5 Founding) is configured via `packages/shared/src/pricing.ts` in the private operational repo and per-customer overrides in `platform_pricing` (DB). README documents this stratification explicitly. CI: `.github/workflows/lean-build.yml` runs `lake build` plus a sorry/admit grep-gate on every PR touching lean-verification/. License: MIT — same as the rest of protocol-commerce. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/lean-build.yml | 44 ++++ lean-verification/.gitignore | 4 + lean-verification/NexbidVerify.lean | 15 ++ lean-verification/NexbidVerify/Auction.lean | 141 ++++++++++++ lean-verification/NexbidVerify/Budget.lean | 86 ++++++++ .../NexbidVerify/Commerce/DSL.lean | 51 +++++ .../NexbidVerify/Commerce/Policy.lean | 59 ++++++ .../NexbidVerify/Commerce/Revenue.lean | 48 +++++ .../NexbidVerify/Consistency.lean | 175 +++++++++++++++ lean-verification/NexbidVerify/EndToEnd.lean | 55 +++++ lean-verification/NexbidVerify/KanScore.lean | 52 +++++ lean-verification/NexbidVerify/Monotone.lean | 37 ++++ lean-verification/NexbidVerify/Normalize.lean | 51 +++++ .../NexbidVerify/RatHelpers.lean | 71 +++++++ lean-verification/NexbidVerify/Score.lean | 113 ++++++++++ lean-verification/NexbidVerify/Types.lean | 34 +++ lean-verification/NexbidVerify/Wallet.lean | 153 ++++++++++++++ lean-verification/README.md | 200 ++++++++++++++++++ lean-verification/lake-manifest.json | 5 + lean-verification/lakefile.lean | 11 + lean-verification/lean-toolchain | 1 + 21 files changed, 1406 insertions(+) create mode 100644 .github/workflows/lean-build.yml create mode 100644 lean-verification/.gitignore create mode 100644 lean-verification/NexbidVerify.lean create mode 100644 lean-verification/NexbidVerify/Auction.lean create mode 100644 lean-verification/NexbidVerify/Budget.lean create mode 100644 lean-verification/NexbidVerify/Commerce/DSL.lean create mode 100644 lean-verification/NexbidVerify/Commerce/Policy.lean create mode 100644 lean-verification/NexbidVerify/Commerce/Revenue.lean create mode 100644 lean-verification/NexbidVerify/Consistency.lean create mode 100644 lean-verification/NexbidVerify/EndToEnd.lean create mode 100644 lean-verification/NexbidVerify/KanScore.lean create mode 100644 lean-verification/NexbidVerify/Monotone.lean create mode 100644 lean-verification/NexbidVerify/Normalize.lean create mode 100644 lean-verification/NexbidVerify/RatHelpers.lean create mode 100644 lean-verification/NexbidVerify/Score.lean create mode 100644 lean-verification/NexbidVerify/Types.lean create mode 100644 lean-verification/NexbidVerify/Wallet.lean create mode 100644 lean-verification/README.md create mode 100644 lean-verification/lake-manifest.json create mode 100644 lean-verification/lakefile.lean create mode 100644 lean-verification/lean-toolchain diff --git a/.github/workflows/lean-build.yml b/.github/workflows/lean-build.yml new file mode 100644 index 0000000..782de0f --- /dev/null +++ b/.github/workflows/lean-build.yml @@ -0,0 +1,44 @@ +name: Lean Build + +on: + push: + branches: [main] + paths: + - 'lean-verification/**' + - '.github/workflows/lean-build.yml' + pull_request: + paths: + - 'lean-verification/**' + - '.github/workflows/lean-build.yml' + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Install elan + run: | + curl -sSf https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh | sh -s -- -y --default-toolchain none + echo "$HOME/.elan/bin" >> "$GITHUB_PATH" + + - name: Cache .lake + uses: actions/cache@v4 + with: + path: lean-verification/.lake + key: lake-${{ runner.os }}-${{ hashFiles('lean-verification/lean-toolchain', 'lean-verification/lakefile.lean', 'lean-verification/lake-manifest.json') }} + restore-keys: lake-${{ runner.os }}- + + - name: Build all theorems + working-directory: lean-verification + run: lake build + + - name: Verify zero sorry/admit + working-directory: lean-verification + run: | + if grep -rEn '\bsorry\b|\badmit\b' --include='*.lean' NexbidVerify/; then + echo "::error::Found sorry or admit in proof code" + exit 1 + fi + echo "PASS: zero sorry/admit confirmed" diff --git a/lean-verification/.gitignore b/lean-verification/.gitignore new file mode 100644 index 0000000..6c0bd20 --- /dev/null +++ b/lean-verification/.gitignore @@ -0,0 +1,4 @@ +.lake/ +build/ +*.olean +*.ilean diff --git a/lean-verification/NexbidVerify.lean b/lean-verification/NexbidVerify.lean new file mode 100644 index 0000000..589e261 --- /dev/null +++ b/lean-verification/NexbidVerify.lean @@ -0,0 +1,15 @@ +-- Nexbid Auction Engine — Formal Verification in Lean 4 +-- Full verification stack: Score, Normalization, Auction, Budget, Commerce, Wallet, EndToEnd + +import NexbidVerify.Types +import NexbidVerify.RatHelpers +import NexbidVerify.Score +import NexbidVerify.Normalize +import NexbidVerify.Auction +import NexbidVerify.Budget +import NexbidVerify.Wallet +import NexbidVerify.Commerce.Policy +import NexbidVerify.EndToEnd +import NexbidVerify.Monotone +import NexbidVerify.KanScore +import NexbidVerify.Consistency diff --git a/lean-verification/NexbidVerify/Auction.lean b/lean-verification/NexbidVerify/Auction.lean new file mode 100644 index 0000000..35532da --- /dev/null +++ b/lean-verification/NexbidVerify/Auction.lean @@ -0,0 +1,141 @@ +-- ─── T3-T6: Auction Logic — Formal Verification ────────────────────── +-- Mirrors: runAuction() from packages/auction/src/engine.ts + +import NexbidVerify.Types +import NexbidVerify.Score + +namespace Nexbid + +structure Participant where + campaignId : String + bidCents : Rat + qualityScore : UnitInterval + remainingBudgetCents : Rat + similarity : UnitInterval + contextRelevance : UnitInterval + +def isEligible (p : Participant) (floorPriceCents : Rat) : Prop := + p.remainingBudgetCents ≥ p.bidCents ∧ p.bidCents ≥ floorPriceCents + +instance (p : Participant) (floor : Rat) : Decidable (isEligible p floor) := by + unfold isEligible; exact instDecidableAnd + +def filterEligible (ps : List Participant) (floor : Rat) : List Participant := + ps.filter (fun p => decide (isEligible p floor)) + +-- ─── T3 ────────────────────────────────────────────────────────────── + +/-- **Theorem T3:** Every filtered participant is eligible. -/ +theorem eligibility_correct (ps : List Participant) (floor : Rat) : + ∀ p ∈ filterEligible ps floor, isEligible p floor := by + intro p hp + unfold filterEligible at hp + simp [List.mem_filter] at hp + exact hp.2 + +-- ─── Max selection ─────────────────────────────────────────────────── + +def maxParticipant (f : Participant → Rat) : (l : List Participant) → l ≠ [] → Participant + | [x], _ => x + | x :: y :: ys, _ => + let rest := maxParticipant f (y :: ys) (by simp) + if f x ≥ f rest then x else rest + +-- ─── L1: Tie-breaking uniqueness ──────────────────────────────────── + +/-- **L1:** If two participants have equal scores, the one appearing first in the list wins. + maxParticipant uses ≥ in the if-branch, so when f x = f rest, the head x is returned. -/ +theorem maxParticipant_head_wins_tie (f : Participant → Rat) (x : Participant) (xs : List Participant) + (h : (x :: xs) ≠ []) (hx_in : xs ≠ []) + (h_tie : f x = f (maxParticipant f xs hx_in)) : + maxParticipant f (x :: xs) h = x := by + cases xs with + | nil => exact absurd rfl hx_in + | cons y ys => + show (let rest := maxParticipant f (y :: ys) (by simp); if f x ≥ f rest then x else rest) = x + have h_eq : maxParticipant f (y :: ys) hx_in = maxParticipant f (y :: ys) (by simp) := by congr 1 + rw [h_eq] at h_tie + have hge : f x ≥ f (maxParticipant f (y :: ys) (by simp)) := by + show f (maxParticipant f (y :: ys) (by simp)) ≤ f x + rw [← h_tie] -- goal becomes f x ≤ f x + exact Rat.le_refl + simp [if_pos hge] + +-- ─── T4: maxParticipant returns the maximum ────────────────────────── + +/-- **Theorem T4:** maxParticipant returns element with highest f-value. -/ +theorem maxParticipant_is_max (f : Participant → Rat) + (l : List Participant) (h : l ≠ []) : + ∀ x ∈ l, f x ≤ f (maxParticipant f l h) := by + induction l with + | nil => exact absurd rfl h + | cons a as ih => + intro x hx + cases as with + | nil => + simp [maxParticipant] + have heq : x = a := by + cases List.mem_cons.mp hx with + | inl h => exact h + | inr h => exact absurd (List.not_mem_nil h) (by simp) + rw [heq] + exact Rat.le_refl + | cons b bs => + simp only [maxParticipant] + split + · -- f a ≥ f rest + next hge => + cases List.mem_cons.mp hx with + | inl heq => rw [heq]; exact Rat.le_refl + | inr hmem => exact Rat.le_trans (ih (by simp) x hmem) hge + · -- f a < f rest + next hlt => + cases List.mem_cons.mp hx with + | inl heq => + rw [heq] + simp [GE.ge, Rat.not_le] at hlt + exact Rat.le_of_lt hlt + | inr hmem => exact ih (by simp) x hmem + +/-- **T4b:** maxParticipant returns a member of the list. -/ +theorem maxParticipant_mem (f : Participant → Rat) + (l : List Participant) (h : l ≠ []) : + maxParticipant f l h ∈ l := by + induction l with + | nil => exact absurd rfl h + | cons a as ih => + cases as with + | nil => simp [maxParticipant] + | cons b bs => + simp only [maxParticipant] + split + · exact List.Mem.head _ + · exact List.Mem.tail _ (ih (by simp)) + +-- ─── T5: Winner is eligible ────────────────────────────────────────── + +/-- **Theorem T5:** Winner from filtered list is eligible. -/ +theorem winner_is_eligible (ps : List Participant) (floor : Rat) + (f : Participant → Rat) (h : filterEligible ps floor ≠ []) : + isEligible (maxParticipant f (filterEligible ps floor) h) floor := + eligibility_correct ps floor _ (maxParticipant_mem f _ h) + +-- ─── T6: No winner ↔ no eligible ──────────────────────────────────── + +/-- **T6a:** No eligible → no winner possible. -/ +theorem no_eligible_no_winner (ps : List Participant) (floor : Rat) + (h : filterEligible ps floor = []) : + ∀ p ∈ ps, ¬ isEligible p floor := by + intro p hp helig + have : p ∈ filterEligible ps floor := by + unfold filterEligible; simp [List.mem_filter]; exact ⟨hp, helig⟩ + rw [h] at this; exact List.not_mem_nil this + +/-- **T6b:** Eligible → non-empty filtered list. -/ +theorem eligible_exists_nonempty (ps : List Participant) (floor : Rat) + (p : Participant) (hp : p ∈ ps) (helig : isEligible p floor) : + filterEligible ps floor ≠ [] := by + intro hempty + exact absurd helig (no_eligible_no_winner ps floor hempty p hp) + +end Nexbid diff --git a/lean-verification/NexbidVerify/Budget.lean b/lean-verification/NexbidVerify/Budget.lean new file mode 100644 index 0000000..a25f475 --- /dev/null +++ b/lean-verification/NexbidVerify/Budget.lean @@ -0,0 +1,86 @@ +-- ─── T7: Atomic Budget Decrement — Formal Verification ─────────────── +-- Mirrors: atomicBudgetDecrement() from packages/auction/src/atomic-budget.ts + +namespace Nexbid + +structure BudgetState where + amountCents : Int + spentCents : Int + inv : spentCents ≤ amountCents + spent_nonneg : 0 ≤ spentCents + +theorem BudgetState.remaining_nonneg (b : BudgetState) : + 0 ≤ b.amountCents - b.spentCents := by + have := b.inv; omega + +inductive DecrementResult where + | success (newState : BudgetState) + | insufficientBudget + +/-- Atomic budget decrement with WHERE guard. -/ +def atomicDecrement (state : BudgetState) (deductCents : Int) + (hdeduct : 0 ≤ deductCents) : DecrementResult := + if h : state.amountCents - state.spentCents ≥ deductCents then + DecrementResult.success { + amountCents := state.amountCents + spentCents := state.spentCents + deductCents + inv := by have := state.inv; have := state.spent_nonneg; omega + spent_nonneg := by have := state.spent_nonneg; omega + } + else + DecrementResult.insufficientBudget + +-- ─── T7 theorems ──────────────────────────────────────────────────── + +/-- **T7a:** Invariant preserved. -/ +theorem decrement_preserves_invariant (state : BudgetState) (deductCents : Int) + (hdeduct : 0 ≤ deductCents) (newState : BudgetState) + (_h : atomicDecrement state deductCents hdeduct = DecrementResult.success newState) : + newState.spentCents ≤ newState.amountCents := + newState.inv + +/-- **T7b:** Spent increased exactly. -/ +theorem decrement_spent_exact (state : BudgetState) (deductCents : Int) + (hdeduct : 0 ≤ deductCents) (newState : BudgetState) + (h : atomicDecrement state deductCents hdeduct = DecrementResult.success newState) : + newState.spentCents = state.spentCents + deductCents := by + unfold atomicDecrement at h; split at h + · injection h with h; rw [← h] + · contradiction + +/-- **T7c:** Total unchanged. -/ +theorem decrement_total_unchanged (state : BudgetState) (deductCents : Int) + (hdeduct : 0 ≤ deductCents) (newState : BudgetState) + (h : atomicDecrement state deductCents hdeduct = DecrementResult.success newState) : + newState.amountCents = state.amountCents := by + unfold atomicDecrement at h; split at h + · injection h with h; rw [← h] + · contradiction + +/-- **T7d:** Failure means insufficient budget. -/ +theorem decrement_fail_means_insufficient (state : BudgetState) (deductCents : Int) + (hdeduct : 0 ≤ deductCents) + (h : atomicDecrement state deductCents hdeduct = DecrementResult.insufficientBudget) : + state.amountCents - state.spentCents < deductCents := by + unfold atomicDecrement at h; split at h + · contradiction + · next hn => omega + +/-- **T7e:** Spent monotone. -/ +theorem decrement_spent_monotone (state : BudgetState) (deductCents : Int) + (hdeduct : 0 ≤ deductCents) (newState : BudgetState) + (h : atomicDecrement state deductCents hdeduct = DecrementResult.success newState) : + state.spentCents ≤ newState.spentCents := by + have := decrement_spent_exact state deductCents hdeduct newState h; omega + +/-- **Theorem T7 (budget_never_overspent):** Main safety property. + Uses explicit unfolding to avoid match/simp issues. -/ +theorem budget_never_overspent (state : BudgetState) (deductCents : Int) + (hdeduct : 0 ≤ deductCents) : + ∀ newState : BudgetState, + atomicDecrement state deductCents hdeduct = DecrementResult.success newState → + newState.spentCents ≤ newState.amountCents := by + intro newState _h + exact newState.inv + +end Nexbid diff --git a/lean-verification/NexbidVerify/Commerce/DSL.lean b/lean-verification/NexbidVerify/Commerce/DSL.lean new file mode 100644 index 0000000..f28b85b --- /dev/null +++ b/lean-verification/NexbidVerify/Commerce/DSL.lean @@ -0,0 +1,51 @@ +import NexbidVerify.Types + +namespace Commerce + +/-- A revenue split between two parties, guaranteed to sum to 1. -/ +structure RevenueShare where + publisherShare : Rat + platformShare : Rat + shares_nonneg : 0 ≤ publisherShare ∧ 0 ≤ platformShare + shares_sum_one : publisherShare + platformShare = 1 + +/-- Default 70/30 revenue share. -/ +def defaultRevenueShare : RevenueShare := { + publisherShare := 7 / 10 + platformShare := 3 / 10 + shares_nonneg := ⟨by native_decide, by native_decide⟩ + shares_sum_one := by native_decide +} + +/-- Revenue computed from a bid and a revenue share. -/ +structure RevenueResult where + publisherRevenue : Rat + platformRevenue : Rat + totalRevenue : Rat + +/-- Compute revenue from a winning bid amount and share. -/ +def computeRevenue (bidAmountCents : Rat) (share : RevenueShare) : RevenueResult := { + publisherRevenue := bidAmountCents * share.publisherShare + platformRevenue := bidAmountCents * share.platformShare + totalRevenue := bidAmountCents +} + +/-- A policy constraint that a bid must satisfy. -/ +structure PolicyConstraint where + maxBudgetCents : Rat + spentCents : Rat + floorCents : Rat + allowedCategories : List String + budget_nonneg : 0 ≤ maxBudgetCents + spent_nonneg : 0 ≤ spentCents + spent_le_budget : spentCents ≤ maxBudgetCents + floor_nonneg : 0 ≤ floorCents + +/-- Check if a bid satisfies a policy constraint. -/ +def policyCheck (bidCents : Rat) (category : String) + (policy : PolicyConstraint) : Bool := + decide (bidCents ≤ policy.maxBudgetCents - policy.spentCents) && + decide (policy.floorCents ≤ bidCents) && + policy.allowedCategories.contains category + +end Commerce diff --git a/lean-verification/NexbidVerify/Commerce/Policy.lean b/lean-verification/NexbidVerify/Commerce/Policy.lean new file mode 100644 index 0000000..5f22568 --- /dev/null +++ b/lean-verification/NexbidVerify/Commerce/Policy.lean @@ -0,0 +1,59 @@ +-- ─── Commerce Policy — Formal Verification ─────────────────────────── +-- Mirrors: policyCheck from packages/commerce/src/policy.ts +-- Bridges Commerce layer constraints with Auction layer eligibility. + +import NexbidVerify.Types +import NexbidVerify.Auction + +namespace Nexbid + +-- ─── Policy types ────────────────────────────────────────────────────── + +structure PolicyConstraint where + minBidCents : Rat + allowedCategories : List String + +/-- Policy check: bid meets floor AND category is allowed. -/ +def policyCheck (bidCents : Rat) (category : String) (policy : PolicyConstraint) : Bool := + (decide (bidCents ≥ policy.minBidCents)) && policy.allowedCategories.contains category + +-- ─── L4: T17b Category membership proposition ───────────────────────── + +/-- **L4 (T17b):** If policyCheck passes, the category is in the allowed list. -/ +theorem policy_category_check (bidCents : Rat) (category : String) + (policy : PolicyConstraint) + (h : policyCheck bidCents category policy = true) : + policy.allowedCategories.contains category = true := by + unfold policyCheck at h + simp [Bool.and_eq_true] at h + exact List.contains_iff_mem.mpr h.2 + +-- ─── L4b: If policyCheck passes, bid meets floor ────────────────────── + +/-- **L4b:** If policyCheck passes, the bid is at least the minimum. -/ +theorem policy_bid_meets_floor (bidCents : Rat) (category : String) + (policy : PolicyConstraint) + (h : policyCheck bidCents category policy = true) : + bidCents ≥ policy.minBidCents := by + unfold policyCheck at h + simp [Bool.and_eq_true] at h + exact h.1 + +-- ─── L2: Bridge policyCheck → isEligible ────────────────────────────── + +/-- **L2:** If policyCheck passes with floor as minBidCents, and participant has + sufficient budget, then isEligible holds. This bridges Commerce ↔ Auction. -/ +theorem policy_implies_eligible (p : Participant) (floor : Rat) (category : String) + (policy : PolicyConstraint) + (h_floor_match : policy.minBidCents = floor) + (h_budget : p.remainingBudgetCents ≥ p.bidCents) + (h_policy : policyCheck p.bidCents category policy = true) : + isEligible p floor := by + unfold isEligible + constructor + · exact h_budget + · have h_bid := policy_bid_meets_floor p.bidCents category policy h_policy + rw [h_floor_match] at h_bid + exact h_bid + +end Nexbid diff --git a/lean-verification/NexbidVerify/Commerce/Revenue.lean b/lean-verification/NexbidVerify/Commerce/Revenue.lean new file mode 100644 index 0000000..4bfcc98 --- /dev/null +++ b/lean-verification/NexbidVerify/Commerce/Revenue.lean @@ -0,0 +1,48 @@ +-- NexbidVerify/Commerce/Revenue.lean +import NexbidVerify.Commerce.DSL + +namespace Commerce + +/-- T8: Revenue shares always sum to total bid. -/ +theorem revenue_share_correct (bidAmountCents : Rat) (share : RevenueShare) : + let r := computeRevenue bidAmountCents share + r.publisherRevenue + r.platformRevenue = r.totalRevenue := by + simp [computeRevenue] + rw [← Rat.mul_add, share.shares_sum_one, Rat.mul_one] + +/-- T9: No negative revenue for non-negative bids. -/ +theorem revenue_nonneg (bidAmountCents : Rat) (share : RevenueShare) + (hbid : 0 ≤ bidAmountCents) : + let r := computeRevenue bidAmountCents share + 0 ≤ r.publisherRevenue ∧ 0 ≤ r.platformRevenue := by + simp [computeRevenue] + exact ⟨Rat.mul_nonneg hbid share.shares_nonneg.1, Rat.mul_nonneg hbid share.shares_nonneg.2⟩ + +/-- T10: Revenue never exceeds bid amount. -/ +theorem revenue_le_bid (bidAmountCents : Rat) (share : RevenueShare) + (hbid : 0 ≤ bidAmountCents) : + let r := computeRevenue bidAmountCents share + r.publisherRevenue ≤ r.totalRevenue ∧ r.platformRevenue ≤ r.totalRevenue := by + simp [computeRevenue] + constructor + · have h1 : share.publisherShare ≤ 1 := by + have key : share.publisherShare + 0 ≤ share.publisherShare + share.platformShare := + Rat.add_le_add_left.mpr share.shares_nonneg.2 + rw [Rat.add_zero] at key + rw [share.shares_sum_one] at key + exact key + calc bidAmountCents * share.publisherShare + ≤ bidAmountCents * 1 := Rat.mul_le_mul_of_nonneg_left h1 hbid + _ = bidAmountCents := Rat.mul_one _ + · have h2 : share.platformShare ≤ 1 := by + have key : share.platformShare + 0 ≤ share.platformShare + share.publisherShare := + Rat.add_le_add_left.mpr share.shares_nonneg.1 + rw [Rat.add_zero] at key + rw [Rat.add_comm] at key + rw [share.shares_sum_one] at key + exact key + calc bidAmountCents * share.platformShare + ≤ bidAmountCents * 1 := Rat.mul_le_mul_of_nonneg_left h2 hbid + _ = bidAmountCents := Rat.mul_one _ + +end Commerce diff --git a/lean-verification/NexbidVerify/Consistency.lean b/lean-verification/NexbidVerify/Consistency.lean new file mode 100644 index 0000000..d628ae5 --- /dev/null +++ b/lean-verification/NexbidVerify/Consistency.lean @@ -0,0 +1,175 @@ +-- ─── T8: Consistency ε-Bound ────────────────────────────────────────── +-- Proves: The difference between scores under two weight sets is bounded +-- by 4 * maxCoeffDiff, provided per-weight differences are bounded. +-- +-- Proof strategy: +-- 1. Both scores are linear: score = Σ w_i * x_i +-- 2. Difference = Σ (w1_i - w2_i) * x_i +-- 3. Each (Δw_i * x_i) ∈ [-d, d] when (Δw_i) ∈ [-d, d] and x_i ∈ [0, 1]. +-- Proven by case-split on the sign of Δw_i (using Rat.le_total). +-- 4. Sum of 4 such bounded terms is in [-4d, 4d]. +-- +-- We express the bound without Rat absolute value using two-sided +-- inequalities: -ε ≤ diff ≤ ε. No Mathlib dependency — uses our own +-- RatHelpers module for the few stdlib gaps (sub_mul, four_mul_eq_sum, +-- mul_nonpos_of_nonpos_of_nonneg, one_sub_nonneg_of_le_one). + +import NexbidVerify.Types +import NexbidVerify.Score +import NexbidVerify.RatHelpers + +namespace Nexbid + +open Nexbid.RatHelpers + +/-- Maximum per-coefficient difference between KAN and linear weights (0.005). -/ +def maxCoeffDiff : Rat := 5 / 1000 + +-- ─── Helper lemmas ────────────────────────────────────────────────────── + +/-- `Δ * x ≤ d` whenever `Δ ≤ d` and `0 ≤ x ≤ 1` and `0 ≤ d`. Case-split on + the sign of `Δ`. -/ +private theorem mul_unit_upper + (Δ d : Rat) (h_hi : Δ ≤ d) (h_d_nn : 0 ≤ d) + (x : UnitInterval) : + Δ * x.val ≤ d := by + rcases @Rat.le_total 0 Δ with hΔ | hΔ + · -- Case 0 ≤ Δ: Δ * x ≤ Δ * 1 = Δ ≤ d + have h1 : Δ * x.val ≤ Δ * 1 := Rat.mul_le_mul_of_nonneg_left x.le_one hΔ + rw [Rat.mul_one] at h1 + exact Rat.le_trans h1 h_hi + · -- Case Δ ≤ 0: Δ * x ≤ 0 ≤ d + have h_nonpos : Δ * x.val ≤ 0 := mul_nonpos_of_nonpos_of_nonneg hΔ x.ge_zero + exact Rat.le_trans h_nonpos h_d_nn + +/-- `-d ≤ Δ * x` whenever `-d ≤ Δ` and `0 ≤ x ≤ 1` and `0 ≤ d`. -/ +private theorem mul_unit_lower + (Δ d : Rat) (h_lo : -d ≤ Δ) (h_d_nn : 0 ≤ d) + (x : UnitInterval) : + -d ≤ Δ * x.val := by + rcases @Rat.le_total 0 Δ with hΔ | hΔ + · -- Case 0 ≤ Δ: 0 ≤ Δ * x and -d ≤ 0 + have h_pos : 0 ≤ Δ * x.val := Rat.mul_nonneg hΔ x.ge_zero + exact Rat.le_trans (neg_nonpos_of_nonneg h_d_nn) h_pos + · -- Case Δ ≤ 0: Δ * x ≥ Δ ≥ -d. + -- Show via 0 ≤ (-Δ) * (1 - x.val) = Δ * x.val - Δ. + have h_neg_Δ_nn : 0 ≤ -Δ := neg_nonneg_of_nonpos hΔ + have h_one_sub_x_nn : 0 ≤ 1 - x.val := one_sub_nonneg_of_le_one x.le_one + have h_prod_nn : 0 ≤ (-Δ) * (1 - x.val) := + Rat.mul_nonneg h_neg_Δ_nn h_one_sub_x_nn + -- Algebraic identity: (-Δ) * (1 - x.val) = Δ * x.val - Δ. + have h_id : (-Δ) * (1 - x.val) = Δ * x.val - Δ := by + rw [mul_sub, Rat.mul_one, Rat.neg_mul, Rat.sub_eq_add_neg, Rat.sub_eq_add_neg, + Rat.neg_neg, Rat.add_comm] + rw [h_id] at h_prod_nn + -- 0 ≤ Δ * x.val - Δ. Add Δ to both sides: Δ ≤ Δ * x.val. + -- Use Rat.le_iff_sub_nonneg in reverse. + have h_lhs : Δ ≤ Δ * x.val := (Rat.le_iff_sub_nonneg Δ (Δ * x.val)).mpr h_prod_nn + exact Rat.le_trans h_lo h_lhs + +-- ─── Sum-of-4 bounds ──────────────────────────────────────────────────── + +private theorem sum4_upper + (a b c e d : Rat) + (ha : a ≤ d) (hb : b ≤ d) (hc : c ≤ d) (he : e ≤ d) : + a + b + c + e ≤ 4 * d := by + have h1 : a + b ≤ d + d := + Rat.le_trans (Rat.add_le_add_right.mpr ha) (Rat.add_le_add_left.mpr hb) + have h2 : (a + b) + c ≤ (d + d) + d := + Rat.le_trans (Rat.add_le_add_right.mpr h1) (Rat.add_le_add_left.mpr hc) + have h3 : ((a + b) + c) + e ≤ ((d + d) + d) + d := + Rat.le_trans (Rat.add_le_add_right.mpr h2) (Rat.add_le_add_left.mpr he) + have h_eq : (4 : Rat) * d = ((d + d) + d) + d := four_mul_eq_sum d + rw [h_eq] + exact h3 + +private theorem sum4_lower + (a b c e d : Rat) + (ha : -d ≤ a) (hb : -d ≤ b) (hc : -d ≤ c) (he : -d ≤ e) : + -(4 * d) ≤ a + b + c + e := by + have h1 : -d + -d ≤ a + b := + Rat.le_trans (Rat.add_le_add_right.mpr ha) (Rat.add_le_add_left.mpr hb) + have h2 : (-d + -d) + -d ≤ (a + b) + c := + Rat.le_trans (Rat.add_le_add_right.mpr h1) (Rat.add_le_add_left.mpr hc) + have h3 : ((-d + -d) + -d) + -d ≤ ((a + b) + c) + e := + Rat.le_trans (Rat.add_le_add_right.mpr h2) (Rat.add_le_add_left.mpr he) + have h_eq : -(4 * d) = ((-d + -d) + -d) + -d := neg_four_mul_eq_sum d + rw [h_eq] + exact h3 + +-- ─── T8: Score consistency ───────────────────────────────────────────── + +/-- **Theorem T8 (score_consistency):** + If each weight differs by at most maxCoeffDiff between two weight sets, + then the scores differ by at most 4 * maxCoeffDiff = 0.02. + + Two-sided bound (no Rat abs): + -(4 * maxCoeffDiff) ≤ score(w1) - score(w2) ≤ 4 * maxCoeffDiff + + The bound is tight: achieved when all x_i = 1 and all weight diffs = ±maxCoeffDiff. -/ +theorem score_consistency + (x1 x2 x3 x4 : UnitInterval) + (w1 w2 : AuctionWeights) + (h_b_lo : -(maxCoeffDiff) ≤ w1.bidWeight - w2.bidWeight) + (h_b_hi : w1.bidWeight - w2.bidWeight ≤ maxCoeffDiff) + (h_s_lo : -(maxCoeffDiff) ≤ w1.similarityWeight - w2.similarityWeight) + (h_s_hi : w1.similarityWeight - w2.similarityWeight ≤ maxCoeffDiff) + (h_q_lo : -(maxCoeffDiff) ≤ w1.qualityWeight - w2.qualityWeight) + (h_q_hi : w1.qualityWeight - w2.qualityWeight ≤ maxCoeffDiff) + (h_c_lo : -(maxCoeffDiff) ≤ w1.contextWeight - w2.contextWeight) + (h_c_hi : w1.contextWeight - w2.contextWeight ≤ maxCoeffDiff) : + -(4 * maxCoeffDiff) ≤ + (computeAuctionScore x1 x2 x3 x4 w1 - computeAuctionScore x1 x2 x3 x4 w2) ∧ + (computeAuctionScore x1 x2 x3 x4 w1 - computeAuctionScore x1 x2 x3 x4 w2) ≤ + 4 * maxCoeffDiff := by + -- maxCoeffDiff = 5/1000 ≥ 0 + have h_d_nn : (0 : Rat) ≤ maxCoeffDiff := by + unfold maxCoeffDiff; native_decide + -- Bound each (Δw_i * x_i). + have b_lo := mul_unit_lower (w1.bidWeight - w2.bidWeight) maxCoeffDiff h_b_lo h_d_nn x1 + have b_hi := mul_unit_upper (w1.bidWeight - w2.bidWeight) maxCoeffDiff h_b_hi h_d_nn x1 + have s_lo := mul_unit_lower (w1.similarityWeight - w2.similarityWeight) maxCoeffDiff h_s_lo h_d_nn x2 + have s_hi := mul_unit_upper (w1.similarityWeight - w2.similarityWeight) maxCoeffDiff h_s_hi h_d_nn x2 + have q_lo := mul_unit_lower (w1.qualityWeight - w2.qualityWeight) maxCoeffDiff h_q_lo h_d_nn x3 + have q_hi := mul_unit_upper (w1.qualityWeight - w2.qualityWeight) maxCoeffDiff h_q_hi h_d_nn x3 + have c_lo := mul_unit_lower (w1.contextWeight - w2.contextWeight) maxCoeffDiff h_c_lo h_d_nn x4 + have c_hi := mul_unit_upper (w1.contextWeight - w2.contextWeight) maxCoeffDiff h_c_hi h_d_nn x4 + -- Sum the four bounded terms. + have sum_lo := sum4_lower + ((w1.bidWeight - w2.bidWeight) * x1.val) + ((w1.similarityWeight - w2.similarityWeight) * x2.val) + ((w1.qualityWeight - w2.qualityWeight) * x3.val) + ((w1.contextWeight - w2.contextWeight) * x4.val) + maxCoeffDiff + b_lo s_lo q_lo c_lo + have sum_hi := sum4_upper + ((w1.bidWeight - w2.bidWeight) * x1.val) + ((w1.similarityWeight - w2.similarityWeight) * x2.val) + ((w1.qualityWeight - w2.qualityWeight) * x3.val) + ((w1.contextWeight - w2.contextWeight) * x4.val) + maxCoeffDiff + b_hi s_hi q_hi c_hi + -- Show that the score difference equals the sum of these four products. + have eq_diff : + computeAuctionScore x1 x2 x3 x4 w1 - computeAuctionScore x1 x2 x3 x4 w2 + = (w1.bidWeight - w2.bidWeight) * x1.val + + (w1.similarityWeight - w2.similarityWeight) * x2.val + + (w1.qualityWeight - w2.qualityWeight) * x3.val + + (w1.contextWeight - w2.contextWeight) * x4.val := by + unfold computeAuctionScore + -- Per-term factoring: (a - b) * c = a*c - b*c via sub_mul. + rw [show (w1.bidWeight - w2.bidWeight) * x1.val + = w1.bidWeight * x1.val - w2.bidWeight * x1.val from sub_mul _ _ _, + show (w1.similarityWeight - w2.similarityWeight) * x2.val + = w1.similarityWeight * x2.val - w2.similarityWeight * x2.val from sub_mul _ _ _, + show (w1.qualityWeight - w2.qualityWeight) * x3.val + = w1.qualityWeight * x3.val - w2.qualityWeight * x3.val from sub_mul _ _ _, + show (w1.contextWeight - w2.contextWeight) * x4.val + = w1.contextWeight * x4.val - w2.contextWeight * x4.val from sub_mul _ _ _] + -- Now both sides are sums of (w1_i * x_i) - (w2_i * x_i) terms. + -- Use Rat.sub_eq_add_neg + commutativity to massage into matching form. + simp [Rat.sub_eq_add_neg, Rat.neg_add, Rat.add_left_comm, Rat.add_assoc] + rw [eq_diff] + exact ⟨sum_lo, sum_hi⟩ + +end Nexbid diff --git a/lean-verification/NexbidVerify/EndToEnd.lean b/lean-verification/NexbidVerify/EndToEnd.lean new file mode 100644 index 0000000..c744760 --- /dev/null +++ b/lean-verification/NexbidVerify/EndToEnd.lean @@ -0,0 +1,55 @@ +-- ─── End-to-End Properties — Formal Verification ───────────────────── +-- Cross-cutting concerns: composition of Auction + Budget + Commerce layers. + +import NexbidVerify.Auction +import NexbidVerify.Budget +import NexbidVerify.Commerce.DSL +import NexbidVerify.Commerce.Revenue + +namespace Nexbid + +-- ─── L5: Budget safety across the Rat/Int boundary ────────────────── + +/-- **L5:** Budget safety across the Rat/Int boundary. + If the winner's bid (as Int) fits in the budget, the decrement preserves the invariant. + This bridges Participant.bidCents (Rat) → BudgetState (Int) via an explicit cast hypothesis. -/ +theorem bid_budget_bridge + (ps : List Participant) (floor : Rat) + (f : Participant → Rat) (h_nonempty : filterEligible ps floor ≠ []) + (s : BudgetState) (s' : BudgetState) + (bidInt : Int) (hdeduct : 0 ≤ bidInt) + (_h_cast : bidInt = (maxParticipant f (filterEligible ps floor) h_nonempty).bidCents.num) + (h_ok : atomicDecrement s bidInt hdeduct = DecrementResult.success s') : + s'.spentCents ≤ s'.amountCents := + decrement_preserves_invariant s bidInt hdeduct s' h_ok + +-- ─── T18: Eligible winner budget safe (composition) ───────────────── + +/-- **T18:** Composition of winner selection and budget decrement. + Binds the winner's bidCents to the budget deduction amount. -/ +theorem eligible_winner_budget_safe + (ps : List Participant) (floor : Rat) + (f : Participant → Rat) (h_nonempty : filterEligible ps floor ≠ []) + (s : BudgetState) (deductCents : Int) (s' : BudgetState) (hdeduct : 0 ≤ deductCents) + (_h_bid_match : deductCents = (maxParticipant f (filterEligible ps floor) h_nonempty).bidCents.num) + (h_ok : atomicDecrement s deductCents hdeduct = DecrementResult.success s') : + s'.spentCents ≤ s'.amountCents := + decrement_preserves_invariant s deductCents hdeduct s' h_ok + +-- ─── T19: Full auction invariants ─────────────────────────────────── + +/-- **T19:** Full auction cycle — winner is eligible AND revenue shares compute correctly. -/ +theorem full_auction_invariants + (ps : List Participant) (floor : Rat) + (f : Participant → Rat) (h_nonempty : filterEligible ps floor ≠ []) + (share : Commerce.RevenueShare) : + isEligible (maxParticipant f (filterEligible ps floor) h_nonempty) floor ∧ + Commerce.computeRevenue (maxParticipant f (filterEligible ps floor) h_nonempty).bidCents share = + { publisherRevenue := (maxParticipant f (filterEligible ps floor) h_nonempty).bidCents * share.publisherShare, + platformRevenue := (maxParticipant f (filterEligible ps floor) h_nonempty).bidCents * share.platformShare, + totalRevenue := (maxParticipant f (filterEligible ps floor) h_nonempty).bidCents } := by + constructor + · exact winner_is_eligible ps floor f h_nonempty + · rfl + +end Nexbid diff --git a/lean-verification/NexbidVerify/KanScore.lean b/lean-verification/NexbidVerify/KanScore.lean new file mode 100644 index 0000000..daef2c5 --- /dev/null +++ b/lean-verification/NexbidVerify/KanScore.lean @@ -0,0 +1,52 @@ +-- ─── T1-KAN: KAN Score Boundedness ──────────────────────────────────── +-- Proves: The KAN Variant A (quasi-linear extraction) score is bounded in [0,1]. +-- +-- KAN Variant A raw coefficients: bid=0.204, sim=0.300, qual=0.197, ctx=0.154 +-- Sum = 0.855 (not 1.0, because 4 features use default 0.5 inputs). +-- Normalized: each / 0.855, giving weights that sum to exactly 1. +-- Since normalized weights form valid AuctionWeights, T1 (score_bounded) applies directly. + +import NexbidVerify.Types +import NexbidVerify.Score + +namespace Nexbid + +/-- KAN Variant A: normalized weights summing to 1. + Raw: bid=0.204, sim=0.300, qual=0.197, ctx=0.154 (sum=0.855) + Normalized: each / 0.855 → 204/855 + 300/855 + 197/855 + 154/855 = 855/855 = 1 -/ +def kanWeightsNormalized : AuctionWeights where + bidWeight := 204 / 855 -- ~0.2386 + similarityWeight := 300 / 855 -- ~0.3509 + qualityWeight := 197 / 855 -- ~0.2304 + contextWeight := 154 / 855 -- ~0.1801 + all_nonneg := ⟨by native_decide, by native_decide, by native_decide, by native_decide⟩ + sum_eq_one := by native_decide + +/-- **Theorem T1-KAN (kan_score_bounded):** + The KAN-derived score is always in [0, 1]. + Follows directly from T1 since kanWeightsNormalized is a valid AuctionWeights instance. -/ +theorem kan_score_bounded + (bid sim qual ctx : UnitInterval) : + 0 ≤ computeAuctionScore bid sim qual ctx kanWeightsNormalized ∧ + computeAuctionScore bid sim qual ctx kanWeightsNormalized ≤ 1 := + score_bounded bid sim qual ctx kanWeightsNormalized + +-- ─── Sanity checks ────────────────────────────────────────────────── + +/-- With all inputs = 1, KAN score = 1 -/ +example : computeAuctionScore + ⟨1, by native_decide, by native_decide⟩ + ⟨1, by native_decide, by native_decide⟩ + ⟨1, by native_decide, by native_decide⟩ + ⟨1, by native_decide, by native_decide⟩ + kanWeightsNormalized = 1 := by native_decide + +/-- With all inputs = 0, KAN score = 0 -/ +example : computeAuctionScore + ⟨0, by native_decide, by native_decide⟩ + ⟨0, by native_decide, by native_decide⟩ + ⟨0, by native_decide, by native_decide⟩ + ⟨0, by native_decide, by native_decide⟩ + kanWeightsNormalized = 0 := by native_decide + +end Nexbid diff --git a/lean-verification/NexbidVerify/Monotone.lean b/lean-verification/NexbidVerify/Monotone.lean new file mode 100644 index 0000000..fc30177 --- /dev/null +++ b/lean-verification/NexbidVerify/Monotone.lean @@ -0,0 +1,37 @@ +-- ─── T7: Bid Monotonicity ───────────────────────────────────────────── +-- Proves: Higher bid implies higher auction score (ceteris paribus). +-- This guarantees the auction engine is incentive-compatible: +-- bidding more always results in a strictly better score. + +import NexbidVerify.Types +import NexbidVerify.Score + +namespace Nexbid + +/-- **Theorem T7 (bid_monotone):** + If bid_a > bid_b and all other inputs are identical, + then score(a) > score(b), provided bidWeight > 0. + This is the core incentive-compatibility property. -/ +theorem bid_monotone + (bid_a bid_b : UnitInterval) + (h_gt : bid_a.val > bid_b.val) + (sim qual ctx : UnitInterval) + (w : AuctionWeights) + (h_bw : 0 < w.bidWeight) : + computeAuctionScore bid_a sim qual ctx w > + computeAuctionScore bid_b sim qual ctx w := by + unfold computeAuctionScore + -- Only the first term differs: w.bidWeight * bid_a.val vs w.bidWeight * bid_b.val + -- Since w.bidWeight > 0 and bid_a.val > bid_b.val, the first term is strictly larger. + -- All other terms (similarity, quality, context) are identical, so the sum is strictly larger. + have h : w.bidWeight * bid_a.val > w.bidWeight * bid_b.val := by + exact Rat.mul_lt_mul_of_pos_left h_gt h_bw + have h1 : w.bidWeight * bid_a.val + w.similarityWeight * sim.val > + w.bidWeight * bid_b.val + w.similarityWeight * sim.val := + Rat.add_lt_add_right.mpr h + have h2 : w.bidWeight * bid_a.val + w.similarityWeight * sim.val + w.qualityWeight * qual.val > + w.bidWeight * bid_b.val + w.similarityWeight * sim.val + w.qualityWeight * qual.val := + Rat.add_lt_add_right.mpr h1 + exact Rat.add_lt_add_right.mpr h2 + +end Nexbid diff --git a/lean-verification/NexbidVerify/Normalize.lean b/lean-verification/NexbidVerify/Normalize.lean new file mode 100644 index 0000000..880ebe5 --- /dev/null +++ b/lean-verification/NexbidVerify/Normalize.lean @@ -0,0 +1,51 @@ +-- ─── T2: Bid Normalization ──────────────────────────────────────────── +-- Mirrors: normalizedBid = maxBid > 0 ? p.bidCents / maxBid : 0 +-- from packages/auction/src/engine.ts:149 +-- +-- Theorem T2: normalizeBid always returns a value in [0, 1] + +import NexbidVerify.Types + +namespace Nexbid + +/-- Normalize a bid relative to the maximum bid in the auction. + Returns 0 if maxBid is 0 (no valid bids). -/ +def normalizeBid (bidCents : Rat) (maxBid : Rat) : Rat := + if maxBid > 0 then bidCents / maxBid else 0 + +/-- **Theorem T2a:** normalizeBid is always ≥ 0, + given non-negative bid and positive maxBid. -/ +theorem normalizeBid_nonneg (bidCents maxBid : Rat) + (hbid : 0 ≤ bidCents) (_hmax : 0 ≤ maxBid) : + 0 ≤ normalizeBid bidCents maxBid := by + unfold normalizeBid + split + · next h => + rw [Rat.div_def] + exact Rat.mul_nonneg hbid (Rat.le_of_lt (Rat.inv_pos.mpr h)) + · exact Rat.le_refl + +/-- **Theorem T2b:** normalizeBid is always ≤ 1, + given that bid ≤ maxBid and maxBid ≥ 0. -/ +theorem normalizeBid_le_one (bidCents maxBid : Rat) + (hbid_le_max : bidCents ≤ maxBid) (_hmax : 0 ≤ maxBid) : + normalizeBid bidCents maxBid ≤ 1 := by + unfold normalizeBid + split + · next h => + rw [Rat.div_def] + calc bidCents * maxBid⁻¹ + ≤ maxBid * maxBid⁻¹ := + Rat.mul_le_mul_of_nonneg_right hbid_le_max + (Rat.le_of_lt (Rat.inv_pos.mpr h)) + _ = 1 := Rat.mul_inv_cancel maxBid (Rat.ne_of_gt h) + · exact by native_decide + +/-- **Theorem T2 (combined):** normalizeBid returns a value in [0, 1]. -/ +theorem normalizeBid_bounded (bidCents maxBid : Rat) + (hbid : 0 ≤ bidCents) (hbid_le_max : bidCents ≤ maxBid) (hmax : 0 ≤ maxBid) : + 0 ≤ normalizeBid bidCents maxBid ∧ normalizeBid bidCents maxBid ≤ 1 := + ⟨normalizeBid_nonneg bidCents maxBid hbid hmax, + normalizeBid_le_one bidCents maxBid hbid_le_max hmax⟩ + +end Nexbid diff --git a/lean-verification/NexbidVerify/RatHelpers.lean b/lean-verification/NexbidVerify/RatHelpers.lean new file mode 100644 index 0000000..c399ad6 --- /dev/null +++ b/lean-verification/NexbidVerify/RatHelpers.lean @@ -0,0 +1,71 @@ +-- ─── Rat Helpers — pure-stdlib lemmas for T8 ────────────────────────── +-- Mathlib alternatives we cannot use here (per design decision in +-- lean-verification/README.md "Kein Mathlib"): +-- ring → write algebraic identities as explicit chained rewrites +-- linarith → use Rat.add_le_add_left/right + Rat.le_trans manually +-- nlinarith→ case-split on signs via Rat.le_total +-- +-- This file contains the small set of Rat lemmas we need that are NOT +-- in Lean's standard library so far. Keeping them isolated means future +-- proofs can import the same helpers without chasing stdlib gaps. + +namespace Nexbid.RatHelpers + +-- ─── Distributivity over subtraction ──────────────────────────────────── + +/-- Right-distributivity over subtraction: `(a - b) * c = a * c - b * c`. -/ +theorem sub_mul (a b c : Rat) : (a - b) * c = a * c - b * c := by + rw [Rat.sub_eq_add_neg, Rat.add_mul, Rat.neg_mul, ← Rat.sub_eq_add_neg] + +/-- Left-distributivity over subtraction: `a * (b - c) = a * b - a * c`. -/ +theorem mul_sub (a b c : Rat) : a * (b - c) = a * b - a * c := by + rw [Rat.sub_eq_add_neg, Rat.mul_add, Rat.mul_neg, ← Rat.sub_eq_add_neg] + +-- ─── Sign reasoning ───────────────────────────────────────────────────── + +/-- If `a ≤ 0` then `0 ≤ -a`. -/ +theorem neg_nonneg_of_nonpos {a : Rat} (h : a ≤ 0) : 0 ≤ -a := by + have := Rat.neg_le_neg h + simpa using this + +/-- If `0 ≤ a` then `-a ≤ 0`. -/ +theorem neg_nonpos_of_nonneg {a : Rat} (h : 0 ≤ a) : -a ≤ 0 := by + have := Rat.neg_le_neg h + simpa using this + +/-- If `a ≤ 0` and `0 ≤ b` then `a * b ≤ 0`. -/ +theorem mul_nonpos_of_nonpos_of_nonneg {a b : Rat} (ha : a ≤ 0) (hb : 0 ≤ b) : + a * b ≤ 0 := by + have h_neg : 0 ≤ -a := neg_nonneg_of_nonpos ha + have h_pos : 0 ≤ (-a) * b := Rat.mul_nonneg h_neg hb + -- (-a) * b = -(a * b), so 0 ≤ -(a * b), so a * b ≤ 0. + rw [Rat.neg_mul] at h_pos + have := Rat.neg_le_neg h_pos + simpa using this + +/-- If `1 - x ≥ 0`. Holds for any `x ≤ 1`. -/ +theorem one_sub_nonneg_of_le_one {x : Rat} (h : x ≤ 1) : 0 ≤ 1 - x := by + -- 0 ≤ 1 - x ↔ x ≤ 1 (Rat.le_iff_sub_nonneg, swapped sides) + rw [Rat.sub_eq_add_neg] + -- Show 0 ≤ 1 + (-x), i.e. -x + 1 ≥ 0, i.e. -x ≥ -1. + have h1 : -1 ≤ -x := Rat.neg_le_neg h + -- Add 1 to both sides: -1 + 1 ≤ -x + 1, i.e. 0 ≤ -x + 1. + have h2 : (-1 : Rat) + 1 ≤ -x + 1 := Rat.add_le_add_right.mpr h1 + have h3 : (-1 : Rat) + 1 = 0 := Rat.neg_add_cancel 1 + rw [h3] at h2 + rwa [Rat.add_comm] + +-- ─── 4 = 1 + 1 + 1 + 1 distribution ──────────────────────────────────── + +/-- `4 * d = d + d + d + d` for any rational `d`. -/ +theorem four_mul_eq_sum (d : Rat) : (4 : Rat) * d = d + d + d + d := by + -- 4 = 1 + 1 + 1 + 1, then distribute. + have h4 : (4 : Rat) = 1 + 1 + 1 + 1 := by native_decide + rw [h4, Rat.add_mul, Rat.add_mul, Rat.add_mul, Rat.one_mul] + +/-- `-(4 * d) = (-d) + (-d) + (-d) + (-d)`. -/ +theorem neg_four_mul_eq_sum (d : Rat) : + -(4 * d) = -d + -d + -d + -d := by + rw [four_mul_eq_sum, Rat.neg_add, Rat.neg_add, Rat.neg_add] + +end Nexbid.RatHelpers diff --git a/lean-verification/NexbidVerify/Score.lean b/lean-verification/NexbidVerify/Score.lean new file mode 100644 index 0000000..6de3137 --- /dev/null +++ b/lean-verification/NexbidVerify/Score.lean @@ -0,0 +1,113 @@ +-- ─── Nexbid Auction Score — Formal Verification ────────────────────── +-- Mirrors: computeAuctionScore() from packages/auction/src/engine.ts +-- +-- TypeScript original: +-- score = bw * normalizedBid + sw * similarity + qw * qualityScore + cw * contextRelevance +-- +-- Theorem T1 (score_bounded): +-- If all inputs ∈ [0,1] and weights are non-negative summing to 1, +-- then score ∈ [0,1]. + +import NexbidVerify.Types + +namespace Nexbid + +/-- Compute the weighted auction score. + Direct translation of computeAuctionScore() from engine.ts -/ +def computeAuctionScore + (normalizedBid : UnitInterval) + (similarity : UnitInterval) + (qualityScore : UnitInterval) + (contextRelevance : UnitInterval) + (w : AuctionWeights) : Rat := + w.bidWeight * normalizedBid.val + + w.similarityWeight * similarity.val + + w.qualityWeight * qualityScore.val + + w.contextWeight * contextRelevance.val + +-- ─── Helper lemmas ─────────────────────────────────────────────────── + +private theorem rat_add_le_add {a b c d : Rat} (h1 : a ≤ b) (h2 : c ≤ d) : a + c ≤ b + d := by + have h3 : a + c ≤ a + d := Rat.add_le_add_left.mpr h2 + have h4 : a + d ≤ b + d := Rat.add_le_add_right.mpr h1 + exact Rat.le_trans h3 h4 + +/-- w * x ≤ w when 0 ≤ w and x ≤ 1 -/ +private theorem mul_le_of_le_one (w x : Rat) (hw : 0 ≤ w) (hx : x ≤ 1) : w * x ≤ w := by + have h1 : w * x ≤ w * 1 := Rat.mul_le_mul_of_nonneg_left hx hw + simp [Rat.mul_one] at h1 + exact h1 + +-- ─── T1: Score is bounded in [0, 1] ───────────────────────────────── + +/-- **Theorem T1a (score_lower_bound):** + The auction score is always ≥ 0. -/ +theorem score_nonneg + (normalizedBid similarity qualityScore contextRelevance : UnitInterval) + (w : AuctionWeights) : + 0 ≤ computeAuctionScore normalizedBid similarity qualityScore contextRelevance w := by + unfold computeAuctionScore + apply Rat.add_nonneg + apply Rat.add_nonneg + apply Rat.add_nonneg + · exact Rat.mul_nonneg w.all_nonneg.1 normalizedBid.ge_zero + · exact Rat.mul_nonneg w.all_nonneg.2.1 similarity.ge_zero + · exact Rat.mul_nonneg w.all_nonneg.2.2.1 qualityScore.ge_zero + · exact Rat.mul_nonneg w.all_nonneg.2.2.2 contextRelevance.ge_zero + +/-- **Theorem T1b (score_upper_bound):** + The auction score is always ≤ 1. -/ +theorem score_le_one + (normalizedBid similarity qualityScore contextRelevance : UnitInterval) + (w : AuctionWeights) : + computeAuctionScore normalizedBid similarity qualityScore contextRelevance w ≤ 1 := by + unfold computeAuctionScore + have hbw := mul_le_of_le_one w.bidWeight normalizedBid.val w.all_nonneg.1 normalizedBid.le_one + have hsw := mul_le_of_le_one w.similarityWeight similarity.val w.all_nonneg.2.1 similarity.le_one + have hqw := mul_le_of_le_one w.qualityWeight qualityScore.val w.all_nonneg.2.2.1 qualityScore.le_one + have hcw := mul_le_of_le_one w.contextWeight contextRelevance.val w.all_nonneg.2.2.2 contextRelevance.le_one + calc w.bidWeight * normalizedBid.val + + w.similarityWeight * similarity.val + + w.qualityWeight * qualityScore.val + + w.contextWeight * contextRelevance.val + ≤ w.bidWeight + w.similarityWeight + w.qualityWeight + w.contextWeight := by + apply rat_add_le_add + apply rat_add_le_add + apply rat_add_le_add + · exact hbw + · exact hsw + · exact hqw + · exact hcw + _ = 1 := w.sum_eq_one + +/-- **Theorem T1 (combined):** + computeAuctionScore always returns a value in [0, 1], + given valid inputs and weights. This is a MATHEMATICAL PROOF, + not a test — it covers ALL possible valid inputs. -/ +theorem score_bounded + (normalizedBid similarity qualityScore contextRelevance : UnitInterval) + (w : AuctionWeights) : + 0 ≤ computeAuctionScore normalizedBid similarity qualityScore contextRelevance w ∧ + computeAuctionScore normalizedBid similarity qualityScore contextRelevance w ≤ 1 := + ⟨score_nonneg normalizedBid similarity qualityScore contextRelevance w, + score_le_one normalizedBid similarity qualityScore contextRelevance w⟩ + +-- ─── Sanity checks: concrete computations ─────────────────────────── + +/-- With all inputs = 1 and default weights, score = 1 -/ +example : computeAuctionScore + ⟨1, by native_decide, by native_decide⟩ + ⟨1, by native_decide, by native_decide⟩ + ⟨1, by native_decide, by native_decide⟩ + ⟨1, by native_decide, by native_decide⟩ + defaultWeights = 1 := by native_decide + +/-- With all inputs = 0, score = 0 -/ +example : computeAuctionScore + ⟨0, by native_decide, by native_decide⟩ + ⟨0, by native_decide, by native_decide⟩ + ⟨0, by native_decide, by native_decide⟩ + ⟨0, by native_decide, by native_decide⟩ + defaultWeights = 0 := by native_decide + +end Nexbid diff --git a/lean-verification/NexbidVerify/Types.lean b/lean-verification/NexbidVerify/Types.lean new file mode 100644 index 0000000..54b5aeb --- /dev/null +++ b/lean-verification/NexbidVerify/Types.lean @@ -0,0 +1,34 @@ +-- ─── Nexbid Types ───────────────────────────────────────────────────── +-- Mirrors TypeScript types from packages/auction/src/engine.ts +-- Uses rational numbers (Rat) for exact arithmetic in proofs. + +namespace Nexbid + +/-- A value known to be in the closed interval [0, 1]. -/ +structure UnitInterval where + val : Rat + ge_zero : 0 ≤ val + le_one : val ≤ 1 + +instance : Repr UnitInterval where + reprPrec u _ := repr u.val + +/-- Auction score weights — must be non-negative and sum to 1. -/ +structure AuctionWeights where + bidWeight : Rat + similarityWeight : Rat + qualityWeight : Rat + contextWeight : Rat + all_nonneg : 0 ≤ bidWeight ∧ 0 ≤ similarityWeight ∧ 0 ≤ qualityWeight ∧ 0 ≤ contextWeight + sum_eq_one : bidWeight + similarityWeight + qualityWeight + contextWeight = 1 + +/-- Default weights: 0.3 / 0.3 / 0.2 / 0.2 (matches TypeScript DEFAULT_*_WEIGHT) -/ +def defaultWeights : AuctionWeights where + bidWeight := 3 / 10 + similarityWeight := 3 / 10 + qualityWeight := 2 / 10 + contextWeight := 2 / 10 + all_nonneg := ⟨by native_decide, by native_decide, by native_decide, by native_decide⟩ + sum_eq_one := by native_decide + +end Nexbid diff --git a/lean-verification/NexbidVerify/Wallet.lean b/lean-verification/NexbidVerify/Wallet.lean new file mode 100644 index 0000000..6a06088 --- /dev/null +++ b/lean-verification/NexbidVerify/Wallet.lean @@ -0,0 +1,153 @@ +-- Nexbid Wallet — Formal Verification +-- Mirrors: packages/purchase/src/wallet/*.ts +-- Safety properties W3, W5, W6 from Agent Wallet Protocol Design + +namespace Nexbid + +-- Types + +structure IntentMandate where + maxAmountCents : Int + budgetCents : Int + spentCents : Int + inv_max_le_budget : maxAmountCents ≤ budgetCents + inv_spent_nonneg : 0 ≤ spentCents + inv_spent_le_budget : spentCents ≤ budgetCents + +structure PaymentMandate where + mandateId : Nat + amountCents : Int + inv_positive : 0 < amountCents + +structure PaymentExecute where + mandateId : Nat + amountCents : Int + idempotencyKey : Nat + inv_positive : 0 < amountCents + +inductive ExecuteResult where + | success (receiptAmountCents : Int) + | rejected (reason : String) + | failed (reason : String) + +-- Mandate Validation + +def validatePayment (intent : IntentMandate) (amountCents : Int) + (_hamount : 0 < amountCents) : Bool := + amountCents ≤ intent.maxAmountCents && + intent.spentCents + amountCents ≤ intent.budgetCents + +def executeWalletPay (intent : IntentMandate) (amountCents : Int) + (hamount : 0 < amountCents) : ExecuteResult × IntentMandate := + if h1 : amountCents ≤ intent.maxAmountCents then + if h2 : intent.spentCents + amountCents ≤ intent.budgetCents then + let newIntent : IntentMandate := { + maxAmountCents := intent.maxAmountCents + budgetCents := intent.budgetCents + spentCents := intent.spentCents + amountCents + inv_max_le_budget := intent.inv_max_le_budget + inv_spent_nonneg := by have := intent.inv_spent_nonneg; omega + inv_spent_le_budget := h2 + } + (ExecuteResult.success amountCents, newIntent) + else + (ExecuteResult.rejected "budget_exhausted", intent) + else + (ExecuteResult.rejected "amount_exceeds_max", intent) + +-- W3: payment_le_mandate + +theorem payment_le_mandate (intent : IntentMandate) (amountCents : Int) + (hamount : 0 < amountCents) (receiptAmount : Int) + (h : (executeWalletPay intent amountCents hamount).1 = ExecuteResult.success receiptAmount) : + receiptAmount ≤ intent.maxAmountCents := by + unfold executeWalletPay at h + split at h + · next h1 => + split at h + · injection h with h; omega + · contradiction + · contradiction + +-- W5: receipt_matches_execute + +theorem receipt_matches_execute (intent : IntentMandate) (amountCents : Int) + (hamount : 0 < amountCents) (receiptAmount : Int) + (h : (executeWalletPay intent amountCents hamount).1 = ExecuteResult.success receiptAmount) : + receiptAmount = amountCents := by + unfold executeWalletPay at h + split at h + · split at h + · injection h with h; exact h.symm + · contradiction + · contradiction + +-- W6a: wallet_balance_after_pay + +theorem wallet_balance_after_pay (intent : IntentMandate) (amountCents : Int) + (hamount : 0 < amountCents) : + (executeWalletPay intent amountCents hamount).2.spentCents ≤ + (executeWalletPay intent amountCents hamount).2.budgetCents := by + unfold executeWalletPay + split + · split + · next h1 h2 => exact h2 + · exact intent.inv_spent_le_budget + · exact intent.inv_spent_le_budget + +-- W6b: wallet_spent_monotone + +theorem wallet_spent_monotone (intent : IntentMandate) (amountCents : Int) + (hamount : 0 < amountCents) : + intent.spentCents ≤ (executeWalletPay intent amountCents hamount).2.spentCents := by + unfold executeWalletPay + split + · split + · next h1 h2 => dsimp; omega + · dsimp; omega + · dsimp; omega + +-- W6c: wallet_budget_unchanged + +theorem wallet_budget_unchanged (intent : IntentMandate) (amountCents : Int) + (hamount : 0 < amountCents) : + (executeWalletPay intent amountCents hamount).2.budgetCents = intent.budgetCents := by + unfold executeWalletPay + split + · split <;> rfl + · rfl + +-- Composition: W3 + W6 + +theorem payment_within_all_limits (intent : IntentMandate) (amountCents : Int) + (hamount : 0 < amountCents) (receiptAmount : Int) + (h : (executeWalletPay intent amountCents hamount).1 = ExecuteResult.success receiptAmount) : + receiptAmount ≤ intent.maxAmountCents ∧ + (executeWalletPay intent amountCents hamount).2.spentCents ≤ intent.budgetCents := by + constructor + · exact payment_le_mandate intent amountCents hamount receiptAmount h + · have hbal := wallet_balance_after_pay intent amountCents hamount + have hbudget := wallet_budget_unchanged intent amountCents hamount + omega + +-- ─── L3: Idempotency — replay safety ────────────────────────────────── + +/-- **L3 (W4):** Two payment executions with identical fields are equal. + This formalizes the idempotency contract: if mandateId, amountCents, + idempotencyKey, and the positivity proof are the same, the structs are identical. -/ +theorem idempotency_same_key (exec1 exec2 : PaymentExecute) + (h_same_key : exec1.idempotencyKey = exec2.idempotencyKey) + (h_same_amount : exec1.amountCents = exec2.amountCents) + (h_same_mandate : exec1.mandateId = exec2.mandateId) : + exec1 = exec2 := by + cases exec1 with + | mk m1 a1 k1 p1 => + cases exec2 with + | mk m2 a2 k2 p2 => + simp at h_same_key h_same_amount h_same_mandate + subst h_same_mandate + subst h_same_amount + subst h_same_key + rfl + +end Nexbid diff --git a/lean-verification/README.md b/lean-verification/README.md new file mode 100644 index 0000000..8da6cb0 --- /dev/null +++ b/lean-verification/README.md @@ -0,0 +1,200 @@ +# Nexbid Auction Engine — Formale Verifikation in Lean 4 + +Mathematischer Beweis der Korrektheit der Nexbid Auction Engine. +Nicht Tests (endlich viele Fälle), sondern **Beweise (alle möglichen Inputs)**. + +## Was ist das? + +Die Kern-Algorithmen der Nexbid Auction Engine — Scoring, Normalisierung, +Eligibility-Filter, Winner-Selection und Budget-Management — wurden in +[Lean 4](https://lean-lang.org/) nachgebaut und formal verifiziert. + +**Wenn `lake build` erfolgreich ist, sind alle Beweise korrekt.** +Es gibt keine false positives — der Lean-Compiler ist der Verifier. + +## Bewiesene Eigenschaften + +| Theorem | Eigenschaft | Datei | Geschäftskritisch | +|---------|-------------|-------|-------------------| +| **T1** | `computeAuctionScore` ∈ [0,1] | Score.lean | Score-Overflow verhindern | +| **T2** | `normalizeBid` ∈ [0,1] | Normalize.lean | Normalisierung korrekt | +| **T3** | Gefilterte Teilnehmer sind eligible | Auction.lean | Keine bankrotten Bieter | +| **T4** | Winner hat höchsten Score | Auction.lean | Fairness der Auktion | +| **T4b** | Winner ist Listenmitglied | Auction.lean | Kein Phantom-Winner | +| **T5** | Winner ist eligible | Auction.lean | Winner ist zahlungsfähig | +| **T6a** | Keine eligible → kein Winner | Auction.lean | Kein Phantom-Winner | +| **T6b** | Eligible existiert → Liste nicht leer | Auction.lean | Konsistenz | +| **T7a** | Budget-Invariante erhalten | Budget.lean | Kein Overspend | +| **T7b** | Spent steigt exakt um deductCents | Budget.lean | Exakte Abrechnung | +| **T7c** | Total-Budget ändert sich nie | Budget.lean | Budget-Integrität | +| **T7d** | Failure = Insufficient Budget | Budget.lean | Korrekte Fehlermeldung | +| **T7e** | Spent ist monoton steigend | Budget.lean | Kein Budget-Reset | +| **T7m** | Bid-Monotonie | Monotone.lean | Höheres Gebot → höherer Score (Incentive-kompatibel) | +| **T8** | KAN-↔-linear ε-Bound (vollständig bewiesen via RatHelpers, 2026-04-26) | Consistency.lean | KAN-Shadow-Score weicht max. 0.02 ab | + +**Stand 2026-05-15 (Audit verifiziert):** 47 öffentliche Theoreme über 15 `.lean`-Dateien, **null** `sorry`/`admit`/`axiom`. Die Tabelle oben zeigt die geschäftskritischen Kern-Theoreme; vollständige Liste mit Wallet-, Commerce- und EndToEnd-Theoremen siehe Sektion "Vollständiger Theorem-Index" unten. + +## Vollständiger Theorem-Index (47 Theoreme, Audit 2026-05-15) + +| Datei | Anzahl | Theoreme (Auszug) | +|-------|--------|-------------------| +| `Score.lean` | 6 | score_nonneg, score_le_one, score_bounded | +| `Normalize.lean` | 4 | normalizeBid_nonneg, normalizeBid_le_one | +| `Auction.lean` | 10 | eligibility_correct, maxParticipant_is_max, winner_is_eligible, no_eligible_no_winner | +| `Monotone.lean` | 1 | bid_monotone | +| `Budget.lean` | 8 | T7a-T7e, budget_never_overspent, BudgetState.remaining_nonneg | +| `Wallet.lean` | 9 | payment_le_mandate (W3), receipt_matches_execute (W5), wallet_balance_after_pay (W6a), idempotency_same_key (L3), wallet_spent_monotone | +| `KanScore.lean` | 4 | kan_score_bounded (T1-KAN) | +| `Consistency.lean` | 2 | score_consistency (T8, KAN-↔-linear) | +| `EndToEnd.lean` | 3 | T18 eligible_winner_budget_safe, T19 full_auction_invariants | +| `RatHelpers.lean` | 8 | sub_mul, mul_sub, mul_nonpos_of_nonpos_of_nonneg, four_mul_eq_sum (Stdlib-Lücken) | +| `Commerce/Revenue.lean` | 3 | revenue_share_correct, revenue_nonneg, revenue_le_bid | +| `Commerce/Policy.lean` | 4 | policy_implies_eligibility, policy_category_check | +| `Commerce/DSL.lean` | 3 | (siehe Hinweis zu `defaultRevenueShare` unten) | +| `Types.lean` | 1 | one_sub_le_one_helper | +| **Total** | **47** | alle `sorry`-frei, alle via `lake build` mechanisch verifiziert | + +### Hinweis zur `defaultRevenueShare`-Konstante (70/30) + +Die in `Commerce/DSL.lean` definierte Konstante `defaultRevenueShare = 7/10 + 3/10` ist ein **Library-Default für generische Theorem-Anwendbarkeit**, NICHT die Produktions-Tier-Konfiguration der Nexbid AdCP-Auktion. + +| Schicht | Wert | Quelle | +|---------|------|--------| +| **Lean-4 Library-Default** | 70/30 | `Commerce/DSL.lean::defaultRevenueShare` (diese Codebase) | +| **Produktions-Standard-Tier** | 90/10 (Publisher behält 90%) | `packages/shared/src/pricing.ts::PLATFORM_FEE_STANDARD` (= 0.10) | +| **Founding-Tier (12 Monate)** | 95/5 | `packages/shared/src/pricing.ts::PLATFORM_FEE_FOUNDING` (= 0.05) | +| **Per-Customer-Override** | beliebig | `platform_pricing` DB-Tabelle | + +Die Theoreme T8-T10 (`revenue_share_correct`, `revenue_nonneg`, `revenue_le_bid`) sind **generisch über jede `share : RevenueShare`-Instanz** — sie gelten unabhängig vom Default-Wert. Die Wahl `70/30` ist ausschliesslich ein neutraler Library-Default; die Public-Communication von Nexbid sagt korrekt 90/10 (Standard) bzw. 95/5 (Founding). + +## Was NICHT bewiesen ist (Audit-Disclaimer 2026-05-15) + +Diese Verifikation deckt die **algorithmische Kern-Logik** der Auction-Engine, Budget-Sicherheit, Wallet-Payment-Bounds und KAN-Konsistenz ab. Die folgenden Schichten sind **nicht** durch Lean-4-Theoreme abgesichert, sondern durch Code-Reviews, Tests, etablierte Engineering-Standards und (für sicherheitskritische Pfade) Red-Team-Tests: + +- **Authentifizierung und Authorization (RBAC)** — abgesichert via Clerk + scoped OAuth-Scopes (ADR-036), nicht via Lean +- **CORS, SSRF, Input-Validation** — abgesichert via Engineering-Standards + Red-Team-Tests (`packages/mcp-server/src/__tests__/tier2/red-team.test.ts`, 23 Tests) +- **SQL-Query-Konstruktion und Network-Safety** — abgesichert via Parametrized-Queries (Neon-Driver) + Statement-Splitter (Migration-System) +- **Concurrency unter Real-DB-Load** — Postgres handhabt Atomarität (atomic Budget-Decrement nutzt SQL-Transaktionen) +- **TypeScript-zu-Lean-Implementation-Konformität** — verifiziert durch gespiegelte Funktions-Strukturen + Code-Review, nicht durch verified compiler + +Diese Trennung ist absichtlich: Lean-4-Beweise sind sinnvoll, wo Eigenschaften für **alle möglichen Inputs** garantiert werden müssen (Score-Boundedness, Budget-Invariante). Auth, CORS, Network-Safety hingegen sind Verifikations-Domänen mit anderen Methoden (Red-Team, Pen-Test, Standards-Compliance). + +## Mapping: Lean ↔ TypeScript + +| Lean 4 | TypeScript (Original) | +|--------|----------------------| +| `NexbidVerify/Score.lean` | `packages/auction/src/engine.ts:84-97` | +| `NexbidVerify/Normalize.lean` | `packages/auction/src/engine.ts:143-149` | +| `NexbidVerify/Auction.lean` | `packages/auction/src/engine.ts:109-183` | +| `NexbidVerify/Budget.lean` | `packages/auction/src/atomic-budget.ts:38-69` | + +## Projektstruktur + +``` +lean-verification/ +├── lakefile.lean # Build-Config +├── lean-toolchain # Lean 4 Version (stable) +├── NexbidVerify.lean # Root-Import +└── NexbidVerify/ + ├── Types.lean # UnitInterval, AuctionWeights, defaultWeights + ├── Score.lean # T1: computeAuctionScore bounded [0,1] + ├── Normalize.lean # T2: normalizeBid bounded [0,1] + ├── Auction.lean # T3-T6: Eligibility, Winner-Selection + └── Budget.lean # T7: Atomic Budget Decrement safety +``` + +## Voraussetzungen + +```bash +# Lean 4 installieren (elan = Version Manager) +curl -sSf https://raw.githubusercontent.com/leanprover/elan/master/elan-init.sh | sh -s -- -y +``` + +## Build & Verify + +```bash +# Aus dem Lean-Verzeichnis: +cd lean-verification +lake build + +# ODER aus dem Repo-Root via npm-Skript: +npm run lean:build # nur lake build +npm run lean:verify # lake build + sorry-allowlist guard +``` + +Keine Ausgabe = alle Beweise verifiziert. Bei Fehlern zeigt Lean exakt welcher +Beweis fehlschlägt und warum. + +## Quality-Gate-Integration + +Die formale Verifikation ist Teil des Quality-Gates und läuft auf jedem PR: + +| Gate | Tool | Was wird geprüft | +|------|------|------------------| +| `lake build` | Lean 4 Compiler | Alle Theoreme typchecken — Compiler ist der Verifier | +| `check:lean-sorries` | `scripts/check-lean-sorries.mjs` | Keine neuen `sorry`-Stubs ausser dokumentierten in der Allowlist | +| GitHub Actions | `.github/workflows/lean-verify.yml` | Beides läuft cached + automatisiert auf jedem PR der Lean-Code berührt | + +**Sorry-Allowlist:** Theoreme mit unabgeschlossenem Beweis müssen explizit in +`scripts/check-lean-sorries.mjs` stehen — mit Begründung und Pfadangabe. Ein +verlorener `sorry` (ohne Allowlist-Eintrag) bricht die CI; ein abgeschlossener +Beweis ohne Allowlist-Update bricht ebenfalls (verhindert Stale-Listen). + +**Aktuelle Allowlist (Stand 2026-04-26 abends):** +*leer* — alle Theoreme inkl. T8 sind vollständig bewiesen. T8 wurde via +**Custom-Rat-Helper-Library** (`NexbidVerify/RatHelpers.lean`) komplettiert: +acht eigene Lemmas (`sub_mul`, `mul_sub`, `mul_nonpos_of_nonpos_of_nonneg`, +`one_sub_nonneg_of_le_one`, `four_mul_eq_sum`, `neg_four_mul_eq_sum`, +`neg_nonneg_of_nonpos`, `neg_nonpos_of_nonneg`) ersetzen die fehlenden +Stdlib-Identitäten ohne Mathlib-Dependency. + +## Design-Entscheidungen + +### Rationale Zahlen statt Floats +Lean's `Rat` (exakte Brüche) statt IEEE 754 Floats. `0.3` ist in Floats nicht +exakt darstellbar, aber `3/10` in `Rat` schon. Beweise über exakte Arithmetik +sind stärker als Beweise über Gleitkomma-Approximationen. + +### Int für Budget (Cents) +Budget-Werte sind immer ganzzahlig (Cents). `Int` erlaubt `omega` — Lean's +mächtigen Taktik-Solver für lineare Ganzzahl-Arithmetik. Damit werden +Budget-Beweise fast trivial. + +### Kein Mathlib +Bewusst ohne Mathlib (Lean's grosse Mathematik-Bibliothek) geschrieben. +Vorteil: keine Dependency, schneller Build. Nachteil: mehr manuelle Beweisarbeit +für `Rat`-Lemmas (z.B. `div_nonneg`, `add_le_add`). + +### T8 — vollständig bewiesen via Custom Rat-Helper-Library (2026-04-26) + +`NexbidVerify/Consistency.lean` enthielt ursprünglich ein `sorry` in +`score_consistency`. Lean 4 Stdlib hat drei konkrete Lücken die den Beweis +ohne Mathlib mühsam machen: + +- `ring` / `linarith` / `nlinarith` Tactics fehlen → algebraische Identitäten brauchen explizite Lemma-Ketten +- `Rat.sub_mul` (Distributivität über Subtraktion) ist nicht im Lean-Core +- `Rat.le_total` ist eine `Or`-Proposition mit impliziten Argumenten — `@Rat.le_total 0 Δ` benötigt expliziten Aufruf + +**Holger 2026-04-26 abends entschied: Option 2 — Custom Rat-Helper-Library.** + +`NexbidVerify/RatHelpers.lean` füllt die Lücken mit acht eigenen Lemmas: +- `sub_mul`, `mul_sub` — Distributivität über Subtraktion +- `mul_nonpos_of_nonpos_of_nonneg` — Vorzeichenregel für Produkte +- `one_sub_nonneg_of_le_one` — `0 ≤ 1 - x` wenn `x ≤ 1` +- `neg_nonneg_of_nonpos`, `neg_nonpos_of_nonneg` — Vorzeichen-Negation +- `four_mul_eq_sum`, `neg_four_mul_eq_sum` — `4d = d+d+d+d` Distribution + +Mit diesen Helpers kompiliert T8 sauber (Build-Zeit unter 1 s zusätzlich, +keine externen Dependencies). Die Sorry-Allowlist in +`scripts/check-lean-sorries.mjs` ist jetzt leer — jedes neue `sorry` +bricht die CI. + +## Kontext + +Dies ist vermutlich die erste formale Verifikation einer Ad-Auction-Engine. +Inspiriert durch [Leanstral](https://mistral.ai/news/leanstral) (Mistral AI), +ein spezialisiertes 6B-Parameter-Modell für Lean-4-Beweise. + +**Relevanz:** In einer Welt, in der KI zunehmend Code generiert, wird die +Frage "Ist dieser Code korrekt?" zur Kernfrage. Formale Verifikation gibt +eine mathematische Garantie — nicht nur ein "Tests sind grün". diff --git a/lean-verification/lake-manifest.json b/lean-verification/lake-manifest.json new file mode 100644 index 0000000..3711d15 --- /dev/null +++ b/lean-verification/lake-manifest.json @@ -0,0 +1,5 @@ +{"version": "1.1.0", + "packagesDir": ".lake/packages", + "packages": [], + "name": "«nexbid-verify»", + "lakeDir": ".lake"} diff --git a/lean-verification/lakefile.lean b/lean-verification/lakefile.lean new file mode 100644 index 0000000..37f6e54 --- /dev/null +++ b/lean-verification/lakefile.lean @@ -0,0 +1,11 @@ +import Lake +open Lake DSL + +package «nexbid-verify» where + leanOptions := #[ + ⟨`autoImplicit, false⟩ + ] + +@[default_target] +lean_lib NexbidVerify where + srcDir := "." diff --git a/lean-verification/lean-toolchain b/lean-verification/lean-toolchain new file mode 100644 index 0000000..7638861 --- /dev/null +++ b/lean-verification/lean-toolchain @@ -0,0 +1 @@ +leanprover/lean4:stable