feat(Query): query complexity framework with sorting examples#401
feat(Query): query complexity framework with sorting examples#401kim-em wants to merge 59 commits into
Conversation
Co-authored-by: Eric Wieser <wieser.eric@gmail.com>
…o query-final-squash
Co-authored-by: Shreyas Srinivas <Shreyas4991@users.noreply.github.com> Co-authored-by: Eric Wieser <eric-wieser@users.noreply.github.com> Co-authored-by: Tanner Duve <tannerduve@gmail.com>
12a4d9f to
b331f1b
Compare
|
b331f1b to
0f75466
Compare
Add a framework for proving upper and lower bounds on query complexity of comparison-based algorithms, using `Prog` (free monad over query types) with oracle-parametric evaluation and structural query counting. Results: - Insertion sort: correctness + O(n²) upper bound - Merge sort: correctness + n·⌈log₂ n⌉ upper bound - Lower bound: any correct comparison sort on an infinite type needs ≥ ⌈log₂(n!)⌉ queries (via adversarial pigeonhole on QueryTree depth) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Shrys <shreyasss94@gmail.com>
Add `Prog.cost`, a weighted generalization of `Prog.queriesOn` where each query type can have a different cost. Demonstrate this with complex multiplication: naive (4 muls + 2 adds) vs Gauss's trick (3 muls + 5 adds), proving correctness, exact parametric costs, and the crossover condition. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Shrys <shreyasss94@gmail.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Shrys <shreyasss94@gmail.com>
0f75466 to
7327006
Compare
sgraf812
left a comment
There was a problem hiding this comment.
I think 3 of my 4 items in #401 (review) haven't been addressed yet. I think the doX stuff has been addressed.
| /-- Evaluate a program by answering each query using `oracle`. -/ | ||
| @[expose] def eval (oracle : {ι : Type} → Q ι → ι) : Prog Q α → α | ||
| | .pure a => a | ||
| | .liftBind op cont => eval oracle (cont (oracle op)) |
|
|
||
| This is the free monad specialized to a single fixed-type operation, used to reify | ||
| algorithms as explicit trees for query complexity lower bounds. -/ | ||
| inductive QueryTree (Q : Type) (R : Type) (α : Type) where |
There was a problem hiding this comment.
Delete this file. Use ProgM throughout.
One small nitpick, in the counting of time complexity, the complexity of an oracle call can depend on size/parameters of inputs supplied to it. For example the cost of calling a vertex connectivity query to a subgraph depends on the edge and vertex size of a subgraph. The current definition simply assumes each oracle call costs 1. |
Prog Q α was already a definitional `abbrev` for FreeM Q α; this commit deletes the Prog namespace and moves the eval/queriesOn/cost interpreters to a new Cslib/Algorithms/Lean/Query/FreeM.lean. All call sites in the query subtree (sorting, arith examples, bounds) now refer to FreeM directly. One-step query constructors (LEQuery.ask, ArithQuery.doAdd/ doSub/doMul) now use FreeM.lift rather than raw .liftBind … .pure. QueryTree and the Prog→QueryTree bridge (now FreeM.toQueryTree) remain in place; deleting QueryTree requires generalising the lower-bound lemma and lands separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The QueryTree decision-tree datatype was a single-response-type
specialisation of FreeM, kept around because the existing
combinatorial lower-bound lemma was easier to state with a fixed
response type. This commit ports the lemma directly to FreeM:
FreeM.exists_queriesOn_ge_clog : if every response type has
cardinality ≤ r, n distinct injective oracles force some oracle
to make ≥ ⌈log_r n⌉ queries.
The proof mostly mirrors the QueryTree version, using @liftBind to
bind the existential response type, and one extra ceiling-division
step (Nat.div_le_div_left) to relate the per-node branching factor
to the global bound r.
Sort/LowerBound.lean now applies the FreeM lemma directly, with
LEQuery.fintypeResponse / cardResponse_le_two witnessing that
LEQuery responses are always Bool. The Prog→QueryTree bridge
(toQTOracle / fromQTOracle / toQueryTree / *_eval / *_queriesOn) is
gone; only LEQuery.oracleOf survives, renamed and moved into
Sort/LEQuery.lean.
Both QueryTree.lean and Sort/QueryTree.lean are deleted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ment The threshold theorem `gauss_le_naive` uses `3 * c_add ≤ c_mul` (inclusive), so the section header should say "at least 3×", not "more than 3×". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…liftM Addresses Eric Wieser's review (Mar 5 2026, on the original Prog.lean): "pattern matching on the free monad is exploiting an implementation detail, and that everything should really go through the universal property, FreeM.liftM." All three interpreters are now defined as `liftM` into a target monad: eval : liftM (m := Id) oracle cost : liftM (m := Tally) (fun op => ⟨weight op, oracle op⟩) |>.cost queriesOn : cost oracle (fun _ => 1) where `Tally` is a tiny accumulator monad (a value paired with a `Nat`-valued running cost) introduced in this file with `Monad` and `LawfulMonad` instances. The right primitive turned out to be `def`, not `abbrev`. With `def`, the constructor-form simp lemmas (eval_pure, eval_liftBind, cost_pure, cost_liftBind, queriesOn_pure, queriesOn_liftBind) all reduce by rfl, so downstream proof ergonomics are unchanged from the original pattern-match definitions. simp normal form is determined by the explicit @[simp] theorems rather than opportunistic abbrev unfolding (which would otherwise mix `queriesOn` and `cost _ (fun _ => 1)` forms in goals and confuse omega). Net effect: the universal property is the actual definition, not a post-hoc characterisation. queriesOn_eq_cost_one is rfl. No downstream proof needed updating. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sgraf812 could you take a look again and see if you'd still like me to remove things here? |
| /-- Evaluate a program by answering each query using `oracle`. | ||
| Defined as `liftM` to `Id`, the canonical interpreter into pure values. -/ | ||
| @[expose] def eval (oracle : {ι : Type} → F ι → ι) (p : FreeM F α) : α := | ||
| p.liftM (m := Id) oracle |
There was a problem hiding this comment.
This is missing pure and Id.run
…linter Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| of its input, and produces a sorted list when the oracle implements a total order. -/ | ||
| structure IsSort (sort : List α → FreeM (LEQuery α) (List α)) : Prop where | ||
| /-- The sort produces a permutation of its input, for any oracle. -/ | ||
| perm : ∀ (xs : List α) (oracle : {ι : Type} → LEQuery α ι → ι), |
There was a problem hiding this comment.
Requiring this for even bad oracles is an interesting but reasonable choice.
There was a problem hiding this comment.
Although is it really reasonable? I think it could unnecessarily rule out some high performance implementations. I don't have a concrete example, though...
There was a problem hiding this comment.
The name isSort is misleading because you also include perm inside. More descriptive/explicit name is better e.g., isSortPerm.
| | le (a b : α) : LEQuery α Bool | ||
|
|
||
| /-- Lift `LEQuery.le a b` into a `FreeM` that returns the comparison result. -/ | ||
| @[expose] def LEQuery.ask (a b : α) : FreeM (LEQuery α) Bool := |
There was a problem hiding this comment.
I'd suggest making all these wrappers for FreeM.lift abbrevs
| of its input, and produces a sorted list when the oracle implements a total order. -/ | ||
| structure IsSort (sort : List α → FreeM (LEQuery α) (List α)) : Prop where | ||
| /-- The sort produces a permutation of its input, for any oracle. -/ | ||
| perm : ∀ (xs : List α) (oracle : {ι : Type} → LEQuery α ι → ι), |
There was a problem hiding this comment.
Although is it really reasonable? I think it could unnecessarily rule out some high performance implementations. I don't have a concrete example, though...
| (p.liftM (m := Tally) (fun op => ⟨weight op, oracle op⟩)).cost | ||
|
|
||
| /-- Count the number of queries along the path determined by `oracle`. -/ | ||
| @[expose] def queriesOn (oracle : {ι : Type} → F ι → ι) (p : FreeM F α) : Nat := |
There was a problem hiding this comment.
Minor: I tend to read queriesOn and think "this gives back a trace of the queries done by p" even if it doesn't make sense typically. Maybe cost1? Or countQueries (which nicely matches existing use of count in Std)?
| (orderedInsert x (y :: ys)).eval oracle = | ||
| if oracle (.le x y) then x :: y :: ys | ||
| else y :: (orderedInsert x ys).eval oracle := by | ||
| simp [orderedInsert, LEQuery.ask] |
There was a problem hiding this comment.
LEQuery.ask here hints at a missing simp lemma? Shouldn't eval_ask have fired? Maybe it is fixed by making LEQuery.ask an abbrev, as Eric suggests...
| if oracle (.le x y) | ||
| then x :: (merge xs' (y :: ys')).eval oracle | ||
| else y :: (merge (x :: xs') ys').eval oracle := by | ||
| simp [merge, LEQuery.ask] |
There was a problem hiding this comment.
Similarly here. Is there a reusable simp lemma missing?
| 1 + if oracle (.le x y) | ||
| then (merge xs' (y :: ys')).queriesOn oracle | ||
| else (merge (x :: xs') ys').queriesOn oracle := by | ||
| simp [merge, LEQuery.ask] |
|
|
||
| /-- Count the number of queries along the path determined by `oracle`. -/ | ||
| @[expose] def queriesOn (oracle : {ι : Type} → F ι → ι) (p : FreeM F α) : Nat := | ||
| cost oracle (fun _ => 1) p |
There was a problem hiding this comment.
Queries can have more generic, input-size dependent costs.
| /-- Weighted query cost: each query has a cost given by `weight`, accumulated along the | ||
| oracle-determined path. Defined as `liftM` into the accumulator monad `TimeM`. -/ | ||
| @[expose] def cost (oracle : {ι : Type} → F ι → ι) | ||
| (weight : {ι : Type} → F ι → Nat) (p : FreeM F α) : Nat := | ||
| TimeM.time <| p.liftM fun op => ⟨oracle op, weight op⟩ |
There was a problem hiding this comment.
I don't think there's a good reason to restrict to Nat here.
| @[simp] theorem eval_orderedInsert_nil (oracle : {ι : Type} → LEQuery α ι → ι) (x : α) : | ||
| (orderedInsert x ([] : List α)).eval oracle = [x] := by |
There was a problem hiding this comment.
I think we should just prove that (orderedInsert x l).eval oracle = l.orderedInsert x (fun x y => oracle (.le x y)), and then we can delete most of this file.
| {ix : Type} (p : FreeM F α) (S : Finset ix) (hS : S.Nonempty) | ||
| (oracles : ix → ({ρ : Type} → F ρ → ρ)) | ||
| (h_inj : Set.InjOn (fun i => p.eval (oracles i)) ↑S) : | ||
| ∃ i ∈ S, p.queriesOn (oracles i) ≥ Nat.clog r S.card := by |
|
|
||
| Because the oracle is supplied *after* the program produces its query plan (the `FreeM` tree), | ||
| a sound implementation has no way to "guess" what the oracle would respond. This is the | ||
| foundation of the anti-cheating guarantee for both upper and lower bounds. |
There was a problem hiding this comment.
FreeM is more robust than TimeM, but I understand that FreeM is not immune to cost cheating, right? If so, we should explicitly write a warning/caveat to the users so they aware some weaknesses. In particular, we should write how much trust do we require in this model so that the complexity is counted correctly.
There was a problem hiding this comment.
This is fairly trivial. The scope for cheating is essentially "sneak in operations via pure". This is different from TimeM, where additionally the location and individual cost annotations can vary from place to place which can also cause problems. One must also ideally avoiding adding extraneous typeclass instances and choose Boolean propositions.
This is however one form of "cheating" that I am not too worried about anymore.
-
On the one hand, we do want pure operations when we don't care too much about their implementation details. This came up in my MPI talk.
-
I have two ideas on how to eliminate pure operations based issues with model-level lower bound proofs entirely, in addition to model/input hiding. Can elaborate on Zulip if asked.
| /-! # Merge Sort as a Query Program | ||
|
|
||
| Merge sort implemented as a `FreeM (LEQuery α)`, making all comparison queries explicit. | ||
| Uses an alternating split (odds/evens) to avoid needing `List.length` in the termination |
There was a problem hiding this comment.
Can you elaborate on why using List.length is bad in the termination argument?
There was a problem hiding this comment.
The odd even split uses structural recursion instead of well founded recursion.
| /-- Merge two sorted lists using comparison queries. -/ | ||
| @[expose] def merge (xs ys : List α) : FreeM (LEQuery α) (List α) := | ||
| match xs, ys with | ||
| | [], ys => pure ys |
There was a problem hiding this comment.
| | [], ys => pure ys | |
| | [], ys => pure ys |
return ys has better stylistic reading. This applies to other similar lines.
| @[expose] def orderedInsert (x : α) : List α → FreeM (LEQuery α) (List α) | ||
| | [] => pure [x] | ||
| | y :: ys => do | ||
| let le ← LEQuery.ask x y | ||
| if le then | ||
| pure (x :: y :: ys) | ||
| else do | ||
| let rest ← orderedInsert x ys | ||
| pure (y :: rest) |
There was a problem hiding this comment.
| @[expose] def orderedInsert (x : α) : List α → FreeM (LEQuery α) (List α) | |
| | [] => pure [x] | |
| | y :: ys => do | |
| let le ← LEQuery.ask x y | |
| if le then | |
| pure (x :: y :: ys) | |
| else do | |
| let rest ← orderedInsert x ys | |
| pure (y :: rest) | |
| @[expose] def orderedInsert (x : α) : List α → FreeM (LEQuery α) (List α) | |
| | [] => pure [x] | |
| | y :: ys => do | |
| let le ← LEQuery.ask x y | |
| if le then | |
| pure (x :: y :: ys) | |
| else do | |
| let rest ← orderedInsert x ys | |
| pure (y :: rest) |
Stylistic change suggestion:
@[expose] def orderedInsert (x : α) : List α → FreeM (LEQuery α) (List α)
| [] => return [x]
| y :: ys => do
let le ← LEQuery.ask x y
if le then
return (x :: y :: ys)
else do
let rest ← orderedInsert x ys
return (y :: rest)
This PR implements Sebastian Graf's unified approach to query complexity (discussed in the CSLib Algorithm frameworks thread), combining the strengths of #372 (explicit
Prog/FreeMquery types) and #376 (monad-parametric approach).Programs are
Prog Q α(free monad over query typeQ), and the oracle is supplied after the program produces its query plan — giving anti-cheating guarantees for both upper and lower bounds. No WP/Hoare triple machinery is needed: correctness is just equations aboutProg.eval oracle, and cost is just equations aboutProg.queriesOn oracle.This provides an alternative to the
TimeM-based cost analysis already in the repo: here query counting is structural (derived from theProgtree) rather than annotation-based.New files
Query/Prog.leanProgtype,eval,queriesOn, simp lemmasQuery/Bounds.leanUpperBoundandLowerBounddefinitionsQuery/QueryTree.leanQuery/Sort/LEQuery.leanQuery/Sort/IsSort.leanIsSortcorrectness specificationQuery/Sort/Insertion/{Defs,Lemmas}.leanQuery/Sort/Merge/{Defs,Lemmas}.leanQuery/Sort/QueryTree.leanProg-to-QueryTreebridge + pigeonhole depth lemmaQuery/Sort/LowerBound.leanResults
IsSortinstanceIsSortinstanceIsSorton an infinite type makes ≥ ⌈log₂(n!)⌉ queries. The proof constructs n! distinct total orders via permutations of embedded elements, shows they force distinct sorted outputs, then applies an adversarial pigeonhole argument onQueryTreedepth.🤖 Prepared with Claude Code