cadaques 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cadaques/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """CADAQUES — Cost-Aware Dual Architecture for QUery-Efficient diScovery.
2
+
3
+ An open-source framework for autonomous discovery campaigns:
4
+ any oracle, any driver, one budget. Every query counts.
5
+ """
6
+
7
+ from .core.campaign import Campaign, CampaignResult, DriverStopped
8
+ from .core.cost import Budget, BudgetExceeded, BudgetView, Cost
9
+ from .core.ledger import Ledger, Transaction
10
+ from .core.protocols import Driver, Oracle, Query, Result
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = [
15
+ "Budget", "BudgetExceeded", "BudgetView", "Campaign", "CampaignResult",
16
+ "Cost", "Driver", "DriverStopped", "Ledger", "Oracle", "Query",
17
+ "Result", "Transaction", "__version__",
18
+ ]
@@ -0,0 +1,4 @@
1
+ from .campaign import Campaign, CampaignResult, DriverStopped
2
+ from .cost import Budget, BudgetExceeded, BudgetView, Cost
3
+ from .ledger import Ledger, Transaction
4
+ from .protocols import Driver, Oracle, Query, Result
@@ -0,0 +1,146 @@
1
+ """The Campaign runner: the discovery loop with an accountant inside.
2
+
3
+ The loop is deliberately small. Its one structural commitment is the
4
+ framework's thesis: *every query counts*. Each iteration prices the
5
+ oracle query before committing, meters the driver's decision, and the
6
+ campaign ends when the budget is exhausted — not when an iteration
7
+ counter runs out.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from typing import Literal, Sequence
15
+
16
+ from .cost import Budget, Cost
17
+ from .ledger import Ledger
18
+ from .protocols import Driver, Oracle, Result
19
+
20
+ StopReason = Literal["budget_exhausted", "max_queries", "driver_stopped"]
21
+
22
+
23
+ class DriverStopped(Exception):
24
+ """A Driver may raise this to end a campaign early."""
25
+
26
+
27
+ @dataclass
28
+ class CampaignResult:
29
+ best: Result | None
30
+ history: list[Result]
31
+ ledger: Ledger
32
+ budget: Budget
33
+ stop_reason: StopReason
34
+
35
+ @property
36
+ def n_queries(self) -> int:
37
+ return len(self.history)
38
+
39
+ def trace(self, currency: str = "seconds") -> list[tuple[float, float]]:
40
+ """(cumulative settled cost, best-so-far value) — the signature
41
+ cost-normalized curve of a CADAQUES campaign."""
42
+ points: list[tuple[float, float]] = []
43
+ best: float | None = None
44
+ for tx, running in self.ledger.cumulative():
45
+ if tx.kind != "oracle":
46
+ continue
47
+ value = tx.meta.get("value")
48
+ if value is None:
49
+ continue
50
+ best = value if best is None else max(best, value) if self._maximize else min(best, value)
51
+ points.append((getattr(running, currency), best))
52
+ return points
53
+
54
+ _maximize: bool = field(default=True, repr=False)
55
+
56
+
57
+ class Campaign:
58
+ """Couple one Oracle to one Driver under one Budget."""
59
+
60
+ def __init__(
61
+ self,
62
+ oracle: Oracle,
63
+ driver: Driver,
64
+ budget: Budget,
65
+ *,
66
+ maximize: bool = True,
67
+ meter_driver: bool = True,
68
+ ) -> None:
69
+ self.oracle = oracle
70
+ self.driver = driver
71
+ self.budget = budget
72
+ self.maximize = maximize
73
+ self.meter_driver = meter_driver
74
+ self.ledger = Ledger()
75
+ self.history: list[Result] = []
76
+
77
+ # ------------------------------------------------------------------
78
+ def run(self, max_queries: int | None = None) -> CampaignResult:
79
+ stop_reason: StopReason = "budget_exhausted"
80
+ best: Result | None = None
81
+
82
+ while True:
83
+ if max_queries is not None and len(self.history) >= max_queries:
84
+ stop_reason = "max_queries"
85
+ break
86
+
87
+ # -- driver decision (metered) -------------------------------
88
+ t0 = time.perf_counter()
89
+ try:
90
+ query = self.driver.propose(self.history, self.budget.view())
91
+ except DriverStopped:
92
+ stop_reason = "driver_stopped"
93
+ break
94
+ elapsed = time.perf_counter() - t0
95
+
96
+ driver_cost = Cost()
97
+ if self.meter_driver:
98
+ driver_cost = Cost(seconds=elapsed) + getattr(
99
+ self.driver, "last_proposal_cost", Cost()
100
+ )
101
+ self.budget.charge(driver_cost, settle=True)
102
+ self.ledger.record(
103
+ "driver",
104
+ label=type(self.driver).__name__,
105
+ declared=driver_cost,
106
+ settled=driver_cost,
107
+ )
108
+
109
+ # -- oracle query: price, afford, evaluate, settle ------------
110
+ declared = self.oracle.price(query)
111
+ if not self.budget.can_afford(declared):
112
+ stop_reason = "budget_exhausted"
113
+ break
114
+
115
+ result = self.oracle.evaluate(query)
116
+ self.budget.charge(result.cost, settle=True)
117
+ self.ledger.record(
118
+ "oracle",
119
+ label=type(self.oracle).__name__,
120
+ declared=declared,
121
+ settled=result.cost,
122
+ value=result.value,
123
+ params=dict(result.query.params),
124
+ fidelity=dict(result.query.fidelity),
125
+ )
126
+
127
+ self.history.append(result)
128
+ self.driver.observe(result)
129
+
130
+ if best is None or self._improves(result, best):
131
+ best = result
132
+
133
+ campaign_result = CampaignResult(
134
+ best=best,
135
+ history=self.history,
136
+ ledger=self.ledger,
137
+ budget=self.budget,
138
+ stop_reason=stop_reason,
139
+ )
140
+ campaign_result._maximize = self.maximize
141
+ return campaign_result
142
+
143
+ def _improves(self, candidate: Result, incumbent: Result) -> bool:
144
+ if self.maximize:
145
+ return candidate.value > incumbent.value
146
+ return candidate.value < incumbent.value
cadaques/core/cost.py ADDED
@@ -0,0 +1,138 @@
1
+ """Cost primitives: the first-class citizens of CADAQUES.
2
+
3
+ Every oracle query and every driver decision is priced in a
4
+ multi-currency :class:`Cost` and charged against a single
5
+ :class:`Budget`. Campaigns end when the budget is exhausted,
6
+ not when an iteration counter runs out.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field, replace
12
+ from typing import Iterator, Mapping
13
+
14
+ #: Currencies tracked by the framework. Heterogeneous by design:
15
+ #: a DFT relaxation, a beamline measurement and an LLM call are
16
+ #: not the same kind of expense.
17
+ CURRENCIES: tuple[str, ...] = ("seconds", "cpu_hours", "euros", "tokens")
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class Cost:
22
+ """An immutable multi-currency cost vector.
23
+
24
+ All components default to zero, so ``Cost(seconds=3.0)`` prices a
25
+ 3-second query and ``Cost()`` is the free cost.
26
+ """
27
+
28
+ seconds: float = 0.0
29
+ cpu_hours: float = 0.0
30
+ euros: float = 0.0
31
+ tokens: float = 0.0
32
+
33
+ # -- arithmetic -------------------------------------------------
34
+ def __add__(self, other: "Cost") -> "Cost":
35
+ return Cost(**{c: getattr(self, c) + getattr(other, c) for c in CURRENCIES})
36
+
37
+ def __sub__(self, other: "Cost") -> "Cost":
38
+ return Cost(**{c: getattr(self, c) - getattr(other, c) for c in CURRENCIES})
39
+
40
+ def scaled(self, factor: float) -> "Cost":
41
+ return Cost(**{c: getattr(self, c) * factor for c in CURRENCIES})
42
+
43
+ # -- queries ----------------------------------------------------
44
+ def items(self) -> Iterator[tuple[str, float]]:
45
+ for c in CURRENCIES:
46
+ yield c, getattr(self, c)
47
+
48
+ def as_dict(self) -> dict[str, float]:
49
+ return dict(self.items())
50
+
51
+ @property
52
+ def is_free(self) -> bool:
53
+ return all(v == 0 for _, v in self.items())
54
+
55
+ def dominated_by(self, other: "Cost") -> bool:
56
+ """True if every component of ``self`` is <= that of ``other``."""
57
+ return all(getattr(self, c) <= getattr(other, c) for c in CURRENCIES)
58
+
59
+ @classmethod
60
+ def from_dict(cls, data: Mapping[str, float]) -> "Cost":
61
+ unknown = set(data) - set(CURRENCIES)
62
+ if unknown:
63
+ raise ValueError(f"Unknown cost currencies: {sorted(unknown)}")
64
+ return cls(**{k: float(v) for k, v in data.items()})
65
+
66
+ def __repr__(self) -> str: # compact: only non-zero currencies
67
+ nonzero = ", ".join(f"{c}={v:g}" for c, v in self.items() if v)
68
+ return f"Cost({nonzero or '0'})"
69
+
70
+
71
+ class BudgetExceeded(RuntimeError):
72
+ """Raised when a charge is attempted beyond the campaign budget."""
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class BudgetView:
77
+ """A read-only snapshot of the budget, exposed to Drivers.
78
+
79
+ Drivers may adapt their strategy to the remaining budget (e.g.
80
+ explore early, exploit when funds run low) but cannot mutate it.
81
+ """
82
+
83
+ total: Cost
84
+ spent: Cost
85
+
86
+ @property
87
+ def remaining(self) -> Cost:
88
+ return self.total - self.spent
89
+
90
+ @property
91
+ def fraction_used(self) -> float:
92
+ """Largest used fraction across the currencies with a limit."""
93
+ fractions = [
94
+ getattr(self.spent, c) / getattr(self.total, c)
95
+ for c in CURRENCIES
96
+ if getattr(self.total, c) > 0
97
+ ]
98
+ return max(fractions, default=0.0)
99
+
100
+
101
+ @dataclass
102
+ class Budget:
103
+ """The single budget of a discovery campaign.
104
+
105
+ Only currencies with a positive ``total`` component are limited;
106
+ the rest are tracked but unbounded. Declared costs are checked
107
+ with :meth:`can_afford` *before* a query is issued; settled costs
108
+ are charged unconditionally with ``charge(..., settle=True)`` —
109
+ in the real world one discovers an overrun after paying for it,
110
+ and the ledger keeps the evidence.
111
+ """
112
+
113
+ total: Cost
114
+ spent: Cost = field(default_factory=Cost)
115
+
116
+ def can_afford(self, cost: Cost) -> bool:
117
+ prospective = self.spent + cost
118
+ return all(
119
+ getattr(prospective, c) <= getattr(self.total, c)
120
+ for c in CURRENCIES
121
+ if getattr(self.total, c) > 0
122
+ )
123
+
124
+ def charge(self, cost: Cost, *, settle: bool = False) -> None:
125
+ if not settle and not self.can_afford(cost):
126
+ raise BudgetExceeded(f"Cannot afford {cost}; remaining {self.remaining}")
127
+ self.spent = self.spent + cost
128
+
129
+ @property
130
+ def remaining(self) -> Cost:
131
+ return self.total - self.spent
132
+
133
+ @property
134
+ def exhausted(self) -> bool:
135
+ return not self.can_afford(Cost())
136
+
137
+ def view(self) -> BudgetView:
138
+ return BudgetView(total=replace(self.total), spent=replace(self.spent))
@@ -0,0 +1,92 @@
1
+ """The campaign ledger: every transaction, declared and settled.
2
+
3
+ The ledger is the accounting record of a discovery campaign and,
4
+ almost for free, its provenance and replay substrate: exporting it
5
+ to JSONL yields a complete, reproducible trace of what was asked,
6
+ what it was expected to cost, and what it actually cost.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any, Iterator, Literal, Mapping
16
+
17
+ from .cost import Cost
18
+
19
+ TransactionKind = Literal["oracle", "driver"]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Transaction:
24
+ kind: TransactionKind
25
+ label: str
26
+ declared: Cost
27
+ settled: Cost
28
+ timestamp: float = field(default_factory=time.time)
29
+ meta: Mapping[str, Any] = field(default_factory=dict)
30
+
31
+ @property
32
+ def overrun(self) -> Cost:
33
+ """Settled minus declared: the price of optimism."""
34
+ return self.settled - self.declared
35
+
36
+
37
+ @dataclass
38
+ class Ledger:
39
+ """Append-only record of all campaign transactions."""
40
+
41
+ transactions: list[Transaction] = field(default_factory=list)
42
+
43
+ def record(
44
+ self,
45
+ kind: TransactionKind,
46
+ label: str,
47
+ declared: Cost,
48
+ settled: Cost,
49
+ **meta: Any,
50
+ ) -> Transaction:
51
+ tx = Transaction(kind=kind, label=label, declared=declared, settled=settled, meta=meta)
52
+ self.transactions.append(tx)
53
+ return tx
54
+
55
+ # -- aggregation ------------------------------------------------
56
+ def total(self, kind: TransactionKind | None = None) -> Cost:
57
+ total = Cost()
58
+ for tx in self.transactions:
59
+ if kind is None or tx.kind == kind:
60
+ total = total + tx.settled
61
+ return total
62
+
63
+ def cumulative(self, kind: TransactionKind | None = None) -> Iterator[tuple[Transaction, Cost]]:
64
+ """Yield (transaction, running settled total) pairs."""
65
+ running = Cost()
66
+ for tx in self.transactions:
67
+ if kind is None or tx.kind == kind:
68
+ running = running + tx.settled
69
+ yield tx, running
70
+
71
+ def __len__(self) -> int:
72
+ return len(self.transactions)
73
+
74
+ # -- persistence ------------------------------------------------
75
+ def to_jsonl(self, path: str | Path) -> Path:
76
+ path = Path(path)
77
+ with path.open("w", encoding="utf-8") as fh:
78
+ for tx in self.transactions:
79
+ fh.write(
80
+ json.dumps(
81
+ {
82
+ "kind": tx.kind,
83
+ "label": tx.label,
84
+ "declared": tx.declared.as_dict(),
85
+ "settled": tx.settled.as_dict(),
86
+ "timestamp": tx.timestamp,
87
+ "meta": dict(tx.meta),
88
+ }
89
+ )
90
+ + "\n"
91
+ )
92
+ return path
@@ -0,0 +1,80 @@
1
+ """The dual architecture: the :class:`Oracle` and :class:`Driver` protocols.
2
+
3
+ CADAQUES is agnostic on both sides of the discovery loop:
4
+
5
+ * an **Oracle** is anything that answers queries at a declared price —
6
+ a simulator, a laboratory instrument, an analytic function;
7
+ * a **Driver** is anything that decides what to ask next — random
8
+ search, Bayesian optimization, gradient methods, an LLM agent.
9
+
10
+ Both sides are metered: the campaign runner charges oracle queries
11
+ *and* driver decisions against one budget.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Mapping, Protocol, Sequence, runtime_checkable
18
+
19
+ from .cost import BudgetView, Cost
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Query:
24
+ """A single question to an Oracle.
25
+
26
+ ``params`` are the design/search variables; ``fidelity`` are the
27
+ knobs that trade accuracy for cost (lattice size, mesh density,
28
+ integration time, ...). Keeping fidelity explicit is what makes
29
+ multi-fidelity, cost-aware campaigns first-class citizens.
30
+ """
31
+
32
+ params: Mapping[str, Any]
33
+ fidelity: Mapping[str, Any] = field(default_factory=dict)
34
+ tag: str = ""
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Result:
39
+ """An Oracle's answer, carrying its *settled* (actual) cost."""
40
+
41
+ query: Query
42
+ value: float
43
+ cost: Cost
44
+ info: Mapping[str, Any] = field(default_factory=dict)
45
+
46
+
47
+ @runtime_checkable
48
+ class Oracle(Protocol):
49
+ """Anything that answers queries at a price.
50
+
51
+ ``price`` declares the expected cost *ex ante* so the runner can
52
+ check affordability before committing; ``evaluate`` performs the
53
+ query and reports the *settled* cost inside the Result. Real
54
+ oracles deviate from their declared price — the ledger records
55
+ both, and that discrepancy is itself an observable.
56
+ """
57
+
58
+ def price(self, query: Query) -> Cost:
59
+ ...
60
+
61
+ def evaluate(self, query: Query) -> Result:
62
+ ...
63
+
64
+
65
+ @runtime_checkable
66
+ class Driver(Protocol):
67
+ """Anything that decides what to ask next.
68
+
69
+ ``propose`` receives the full history and a read-only view of the
70
+ budget, enabling budget-aware strategies. ``observe`` feeds the
71
+ settled result back. Drivers that incur their own accountable
72
+ costs beyond wall time (e.g. LLM tokens) may expose a
73
+ ``last_proposal_cost`` attribute, which the runner will charge.
74
+ """
75
+
76
+ def propose(self, history: Sequence[Result], budget: BudgetView) -> Query:
77
+ ...
78
+
79
+ def observe(self, result: Result) -> None:
80
+ ...
@@ -0,0 +1 @@
1
+ from .reference import AnnealedLocalDriver, RandomDriver
@@ -0,0 +1,96 @@
1
+ """Reference drivers.
2
+
3
+ Two deliberately simple strategies that already span the design
4
+ space CADAQUES cares about:
5
+
6
+ * :class:`RandomDriver` — the universal, budget-oblivious baseline
7
+ against which every intelligent driver must justify its cost.
8
+ * :class:`AnnealedLocalDriver` — a budget-*aware* strategy: it
9
+ explores globally while funds are plentiful and contracts around
10
+ the incumbent as the budget runs out, reading the remaining
11
+ fraction from the ``BudgetView`` it receives each iteration.
12
+
13
+ Bayesian optimization, gradient-based and LLM-agent drivers plug in
14
+ through the same two-method protocol.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from typing import Mapping, Sequence
21
+
22
+ import numpy as np
23
+
24
+ from ..core.cost import BudgetView
25
+ from ..core.protocols import Query, Result
26
+
27
+ SearchSpace = Mapping[str, tuple[float, float]]
28
+
29
+
30
+ def _uniform_sample(space: SearchSpace, rng: np.random.Generator) -> dict[str, float]:
31
+ return {k: float(rng.uniform(low, high)) for k, (low, high) in space.items()}
32
+
33
+
34
+ @dataclass
35
+ class RandomDriver:
36
+ """Uniform random search over a box-bounded space."""
37
+
38
+ space: SearchSpace
39
+ fidelity: Mapping[str, int] = field(default_factory=dict)
40
+ seed: int | None = None
41
+
42
+ def __post_init__(self) -> None:
43
+ self._rng = np.random.default_rng(self.seed)
44
+
45
+ def propose(self, history: Sequence[Result], budget: BudgetView) -> Query:
46
+ return Query(params=_uniform_sample(self.space, self._rng), fidelity=dict(self.fidelity))
47
+
48
+ def observe(self, result: Result) -> None: # random search learns nothing
49
+ pass
50
+
51
+
52
+ @dataclass
53
+ class AnnealedLocalDriver:
54
+ """Gaussian local search whose step size anneals with the budget.
55
+
56
+ The proposal is a perturbation of the best point seen so far,
57
+ with standard deviation ``sigma_max`` at the start of the
58
+ campaign shrinking linearly to ``sigma_min`` as
59
+ ``budget.fraction_used`` approaches 1 — spend on exploration
60
+ while rich, on exploitation while poor.
61
+ """
62
+
63
+ space: SearchSpace
64
+ fidelity: Mapping[str, int] = field(default_factory=dict)
65
+ sigma_max: float = 0.5
66
+ sigma_min: float = 0.02
67
+ maximize: bool = True
68
+ seed: int | None = None
69
+
70
+ def __post_init__(self) -> None:
71
+ self._rng = np.random.default_rng(self.seed)
72
+ self._best: Result | None = None
73
+
74
+ # -- Driver protocol ------------------------------------------------
75
+ def propose(self, history: Sequence[Result], budget: BudgetView) -> Query:
76
+ if self._best is None:
77
+ return Query(params=_uniform_sample(self.space, self._rng), fidelity=dict(self.fidelity))
78
+
79
+ frac = min(max(budget.fraction_used, 0.0), 1.0)
80
+ sigma_rel = self.sigma_max + (self.sigma_min - self.sigma_max) * frac
81
+
82
+ params: dict[str, float] = {}
83
+ for key, (low, high) in self.space.items():
84
+ width = high - low
85
+ center = float(self._best.query.params[key])
86
+ proposal = center + self._rng.normal(0.0, sigma_rel * width)
87
+ params[key] = float(np.clip(proposal, low, high))
88
+ return Query(params=params, fidelity=dict(self.fidelity))
89
+
90
+ def observe(self, result: Result) -> None:
91
+ if self._best is None or self._improves(result):
92
+ self._best = result
93
+
94
+ def _improves(self, result: Result) -> bool:
95
+ assert self._best is not None
96
+ return result.value > self._best.value if self.maximize else result.value < self._best.value
@@ -0,0 +1,2 @@
1
+ from .analytic import AnalyticOracle, quadratic_bowl
2
+ from .ising import Ising2DOracle, T_C_EXACT
@@ -0,0 +1,50 @@
1
+ """Analytic oracles: exact functions with synthetic cost models.
2
+
3
+ Useful for unit tests and for controlled driver-benchmarking
4
+ experiments where the ground truth and the cost model are both known
5
+ exactly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Callable, Mapping
13
+
14
+ from ..core.cost import Cost
15
+ from ..core.protocols import Query, Result
16
+
17
+
18
+ @dataclass
19
+ class AnalyticOracle:
20
+ """Wrap ``fn(params) -> float`` as a metered Oracle.
21
+
22
+ ``price_fn`` maps a query to its declared cost (default: a flat
23
+ one-second tariff), letting tests exercise heterogeneous-cost
24
+ scenarios deterministically. The settled cost equals the declared
25
+ cost plus the (negligible) measured evaluation wall time, keeping
26
+ the declared/settled distinction alive even in toy settings.
27
+ """
28
+
29
+ fn: Callable[[Mapping[str, float]], float]
30
+ price_fn: Callable[[Query], Cost] = field(default=lambda _q: Cost(seconds=1.0))
31
+ name: str = "analytic"
32
+
33
+ def price(self, query: Query) -> Cost:
34
+ return self.price_fn(query)
35
+
36
+ def evaluate(self, query: Query) -> Result:
37
+ t0 = time.perf_counter()
38
+ value = float(self.fn(query.params))
39
+ elapsed = time.perf_counter() - t0
40
+ settled = self.price_fn(query) + Cost(seconds=elapsed)
41
+ return Result(query=query, value=value, cost=settled, info={"oracle": self.name})
42
+
43
+
44
+ def quadratic_bowl(center: Mapping[str, float]) -> Callable[[Mapping[str, float]], float]:
45
+ """Negative squared distance to ``center`` (maximum 0 at the center)."""
46
+
47
+ def fn(params: Mapping[str, float]) -> float:
48
+ return -sum((float(params[k]) - float(v)) ** 2 for k, v in center.items())
49
+
50
+ return fn
@@ -0,0 +1,134 @@
1
+ """Canonical illustrative oracle: the two-dimensional Ising model.
2
+
3
+ The campaign it supports is a textbook discovery task with an exact
4
+ analytic answer: locate the critical temperature of the 2D Ising
5
+ model by maximizing the magnetic susceptibility. Onsager (1944):
6
+
7
+ T_c = 2 / ln(1 + sqrt(2)) ≈ 2.269185 (J = k_B = 1)
8
+
9
+ The oracle is a checkerboard-Metropolis Monte Carlo simulation whose
10
+ *fidelity knobs* — lattice size ``L`` and number of measurement
11
+ ``sweeps`` — carry genuine, heterogeneous cost: accuracy is bought
12
+ with compute. This makes it a faithful miniature of real discovery
13
+ oracles, where every answer has a price and better answers cost more.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import time
19
+ from dataclasses import dataclass
20
+
21
+ import numpy as np
22
+
23
+ from ..core.cost import Cost
24
+ from ..core.protocols import Query, Result
25
+
26
+ #: Onsager's exact critical temperature (J = k_B = 1).
27
+ T_C_EXACT: float = 2.0 / np.log(1.0 + np.sqrt(2.0))
28
+
29
+
30
+ @dataclass
31
+ class Ising2DOracle:
32
+ """Metered Monte Carlo oracle for the 2D Ising model.
33
+
34
+ Parameters
35
+ ----------
36
+ default_L, default_sweeps, default_equilibration:
37
+ Fidelity defaults used when a query does not specify them.
38
+ seconds_per_spin_sweep:
39
+ Ex-ante cost model: declared price is proportional to
40
+ ``L**2 * (equilibration + sweeps)``. The settled cost inside
41
+ each Result is the *measured* wall time, so declared/settled
42
+ discrepancies are visible in the ledger by construction.
43
+ seed:
44
+ Base seed; each evaluation derives an independent stream.
45
+ """
46
+
47
+ default_L: int = 24
48
+ default_sweeps: int = 400
49
+ default_equilibration: int = 200
50
+ seconds_per_spin_sweep: float = 2.5e-8
51
+ seed: int | None = None
52
+
53
+ def __post_init__(self) -> None:
54
+ self._seed_sequence = np.random.SeedSequence(self.seed)
55
+
56
+ # -- helpers ------------------------------------------------------
57
+ def _fidelity(self, query: Query) -> tuple[int, int, int]:
58
+ fid = query.fidelity
59
+ L = int(fid.get("L", self.default_L))
60
+ sweeps = int(fid.get("sweeps", self.default_sweeps))
61
+ equilibration = int(fid.get("equilibration", self.default_equilibration))
62
+ if L < 2 or sweeps < 1 or equilibration < 0:
63
+ raise ValueError(f"Invalid fidelity: L={L}, sweeps={sweeps}, eq={equilibration}")
64
+ return L, sweeps, equilibration
65
+
66
+ # -- Oracle protocol ----------------------------------------------
67
+ def price(self, query: Query) -> Cost:
68
+ L, sweeps, equilibration = self._fidelity(query)
69
+ seconds = self.seconds_per_spin_sweep * L * L * (sweeps + equilibration)
70
+ return Cost(seconds=seconds, cpu_hours=seconds / 3600.0)
71
+
72
+ def evaluate(self, query: Query) -> Result:
73
+ T = float(query.params["T"])
74
+ if T <= 0:
75
+ raise ValueError(f"Temperature must be positive, got T={T}")
76
+ L, sweeps, equilibration = self._fidelity(query)
77
+ rng = np.random.default_rng(self._seed_sequence.spawn(1)[0])
78
+
79
+ t0 = time.perf_counter()
80
+ chi, abs_m = _susceptibility(T=T, L=L, sweeps=sweeps, equilibration=equilibration, rng=rng)
81
+ elapsed = time.perf_counter() - t0
82
+
83
+ settled = Cost(seconds=elapsed, cpu_hours=elapsed / 3600.0)
84
+ return Result(
85
+ query=query,
86
+ value=chi,
87
+ cost=settled,
88
+ info={"abs_magnetization": abs_m, "L": L, "sweeps": sweeps, "T": T},
89
+ )
90
+
91
+
92
+ # ----------------------------------------------------------------------
93
+ # Vectorized checkerboard Metropolis
94
+ # ----------------------------------------------------------------------
95
+
96
+ def _neighbor_sum(spins: np.ndarray) -> np.ndarray:
97
+ return (
98
+ np.roll(spins, 1, axis=0)
99
+ + np.roll(spins, -1, axis=0)
100
+ + np.roll(spins, 1, axis=1)
101
+ + np.roll(spins, -1, axis=1)
102
+ )
103
+
104
+
105
+ def _sweep(spins: np.ndarray, beta: float, masks: tuple[np.ndarray, np.ndarray], rng: np.random.Generator) -> None:
106
+ """One full Metropolis sweep via two checkerboard half-updates."""
107
+ for mask in masks:
108
+ delta_e = 2.0 * spins * _neighbor_sum(spins)
109
+ accept = (delta_e <= 0) | (rng.random(spins.shape) < np.exp(-beta * delta_e))
110
+ spins[mask & accept] *= -1
111
+
112
+
113
+ def _susceptibility(
114
+ *, T: float, L: int, sweeps: int, equilibration: int, rng: np.random.Generator
115
+ ) -> tuple[float, float]:
116
+ """Magnetic susceptibility per spin, chi = beta * N * (<m^2> - <|m|>^2)."""
117
+ beta = 1.0 / T
118
+ spins = rng.choice(np.array([-1, 1], dtype=np.int8), size=(L, L)).astype(np.int8)
119
+
120
+ ii, jj = np.indices((L, L))
121
+ checkerboard = (ii + jj) % 2 == 0
122
+ masks = (checkerboard, ~checkerboard)
123
+
124
+ for _ in range(equilibration):
125
+ _sweep(spins, beta, masks, rng)
126
+
127
+ m_samples = np.empty(sweeps)
128
+ for k in range(sweeps):
129
+ _sweep(spins, beta, masks, rng)
130
+ m_samples[k] = spins.mean()
131
+
132
+ abs_m = float(np.abs(m_samples).mean())
133
+ chi = beta * L * L * float((m_samples**2).mean() - abs_m**2)
134
+ return chi, abs_m
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: cadaques
3
+ Version: 0.1.0
4
+ Summary: Cost-Aware Dual Architecture for QUery-Efficient diScovery: autonomous discovery campaigns — any oracle, any driver, one budget.
5
+ Project-URL: Repository, https://github.com/jorgebravoabad/cadaques
6
+ Author-email: Jorge Bravo-Abad <jorge.bravo@uam.es>
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: active-learning,autonomous-discovery,bayesian-optimization,cost-aware,materials-science,self-driving-labs
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Physics
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: numpy>=1.24
17
+ Provides-Extra: dev
18
+ Requires-Dist: mypy; extra == 'dev'
19
+ Requires-Dist: pytest-cov; extra == 'dev'
20
+ Requires-Dist: pytest>=7; extra == 'dev'
21
+ Requires-Dist: ruff; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # CADAQUES
25
+
26
+ **Cost-Aware Dual Architecture for QUery-Efficient diScovery**
27
+
28
+ *An open-source framework for autonomous discovery campaigns: any oracle, any driver, one budget.*
29
+
30
+ CADAQUES decouples autonomous discovery into two symmetric protocols. A metered
31
+ **Oracle** abstracts anything that answers queries at a price — a simulator, a
32
+ laboratory instrument, an analytic function. A **Driver** abstracts anything that
33
+ decides what to ask next — random search, Bayesian optimization, gradient methods,
34
+ LLM agents. Between them sits the framework's one structural commitment: **cost is
35
+ a first-class primitive**. Every oracle query and every driver decision is priced
36
+ in heterogeneous currencies (wall time, CPU hours, euros, tokens) and charged
37
+ against a single campaign budget. Campaigns end when the budget is exhausted, not
38
+ when an iteration counter runs out, and all results are reported as performance
39
+ per unit cost. *Every query counts.*
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install git+https://github.com/jorgebravoabad/cadaques.git
45
+ ```
46
+
47
+ Or, for development:
48
+
49
+ ```bash
50
+ git clone https://github.com/jorgebravoabad/cadaques.git
51
+ cd cadaques
52
+ pip install -e .
53
+ ```
54
+
55
+ A PyPI release is planned; see the roadmap below.
56
+
57
+ ## Quickstart: a discovery campaign with an exact answer
58
+
59
+ Locate the critical temperature of the 2D Ising model by maximizing the magnetic
60
+ susceptibility under a fixed compute budget. Onsager's exact result,
61
+ T_c = 2/ln(1+√2) ≈ 2.269, provides the ground truth against which any driver
62
+ can be validated.
63
+
64
+ ```python
65
+ from cadaques import Budget, Campaign, Cost
66
+ from cadaques.drivers import AnnealedLocalDriver
67
+ from cadaques.oracles import Ising2DOracle, T_C_EXACT
68
+
69
+ oracle = Ising2DOracle(seed=0)
70
+ driver = AnnealedLocalDriver(
71
+ space={"T": (1.5, 3.5)},
72
+ fidelity={"L": 24, "sweeps": 400},
73
+ seed=0,
74
+ )
75
+ campaign = Campaign(oracle, driver, Budget(total=Cost(seconds=30.0)))
76
+
77
+ outcome = campaign.run()
78
+ print(f"Best T = {outcome.best.query.params['T']:.3f} (exact: {T_C_EXACT:.3f})")
79
+ print(f"Queries: {outcome.n_queries}, stop reason: {outcome.stop_reason}")
80
+ print(f"Spent: {outcome.budget.spent}")
81
+ ```
82
+
83
+ ## Design principles
84
+
85
+ - **Dual agnosticism.** Oracles and Drivers are `typing.Protocol` classes with
86
+ two methods each. Anything that speaks the protocol plugs in.
87
+ - **Declared vs. settled cost.** Oracles declare a price *ex ante*
88
+ (`oracle.price(query)`); the actual cost is settled *ex post* inside each
89
+ `Result`. Real oracles deviate from their estimates — the ledger records both,
90
+ and the discrepancy is itself an observable.
91
+ - **Both sides are metered.** Driver decisions cost wall time — and tokens, if the
92
+ driver is an LLM agent. A campaign's economics include the price of intelligence,
93
+ enabling the question: *when does an expensive smart driver beat a cheap dumb one?*
94
+ - **Budget-aware strategies.** Drivers receive a read-only `BudgetView` and may
95
+ adapt: the reference `AnnealedLocalDriver` explores while rich and exploits
96
+ while poor.
97
+ - **The ledger is the provenance.** Every transaction (declared, settled,
98
+ timestamped) exports to JSONL: a complete, replayable trace of the campaign.
99
+
100
+ ## Status and roadmap
101
+
102
+ `0.1.0` — core protocols, campaign runner, multi-currency budget and ledger,
103
+ reference drivers, and a canonical Ising-2D oracle with fidelity-dependent cost.
104
+
105
+ This is an early release: the API may evolve until `1.0`. Planned next steps
106
+ include a Bayesian-optimization driver, campaign replay, expanded documentation,
107
+ a PyPI release, and an LLM-agent driver adapter.
108
+
109
+ ## Citation
110
+
111
+ If you use CADAQUES in academic work, please cite it (see `CITATION.cff`).
112
+ A Zenodo DOI badge will appear here upon release.
113
+
114
+ ## License
115
+
116
+ MIT.
@@ -0,0 +1,15 @@
1
+ cadaques/__init__.py,sha256=TetRVyC7IYAxmdTWbxF6yseU1tq1MKaVBIY0zLxFXJI,659
2
+ cadaques/core/__init__.py,sha256=WIiWii9CPJCJZYZsd8O2CkkquhSUBWkzCPyBlhS6qMo,214
3
+ cadaques/core/campaign.py,sha256=4jBRHHCb7qV6Sz5DgirKbdNC-hs0mo4mTXjNiXNog-g,4826
4
+ cadaques/core/cost.py,sha256=kiPTCg-LrCd2fx7BoV6Np-F3Hejzh4zbe3W27pSGpyo,4619
5
+ cadaques/core/ledger.py,sha256=cYUwS5Zy7AEGZ3JNTAWeZWMobgS3nqa0w745w8QaqR0,2933
6
+ cadaques/core/protocols.py,sha256=KWnYeIy7ioBkq8sHq5nzi7Ppjfb2oVG2-Ty8AcffGjk,2518
7
+ cadaques/drivers/__init__.py,sha256=Kn4JwKQdqZPbpzL5DA3I6AU_sBF5lPeuaoWe4I_8S1Y,57
8
+ cadaques/drivers/reference.py,sha256=v7mgzpP6RBMnZFLyTT74DEriNHpSzzJikynZ2xsb38E,3483
9
+ cadaques/oracles/__init__.py,sha256=7kaO5ZYTW0z1bInWd-t4PBjljfuVbcLLkKP_dqoOkTg,97
10
+ cadaques/oracles/analytic.py,sha256=vE6A3GtmOMm9NAyB011968lN5ekBxy9ula3l8ErSYIQ,1730
11
+ cadaques/oracles/ising.py,sha256=USYLUfDoTET_r-OXFwgtXQvQAA9eQUKEMsel9PbWs1g,5002
12
+ cadaques-0.1.0.dist-info/METADATA,sha256=hStmEClW3GM2lbgir1SNZVV2B8KSBXTdPhIowTWGFas,4664
13
+ cadaques-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
14
+ cadaques-0.1.0.dist-info/licenses/LICENSE,sha256=4B8R4NG-iP-hN4YOpwtjgYINw_ZeOX_uaLeIYQMrnso,1073
15
+ cadaques-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jorge Bravo-Abad
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.