layover 0.1.0.dev0__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.
@@ -0,0 +1,224 @@
1
+ """The oracle contract (ORACLES §1).
2
+
3
+ Every source — an API, a scraped site, a punctuality lookup — implements this and
4
+ publishes a capability descriptor. The solver plans against *capabilities*, never
5
+ against site names, so that losing an oracle degrades a search rather than breaking
6
+ it.
7
+
8
+ Failures are typed, because "no offers" is ambiguous and the ambiguity is dangerous:
9
+ an empty market and a silently broken parser look identical from the outside, and
10
+ only one of them means "stop querying".
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ from abc import ABC, abstractmethod
18
+ from dataclasses import dataclass, field
19
+ from datetime import date
20
+ from enum import StrEnum
21
+ from typing import Any
22
+
23
+ from layover.models import Cabin, Offer, POSContext, TripShape
24
+
25
+
26
+ class FailureKind(StrEnum):
27
+ BLOCKED = "blocked"
28
+ """403/429, a bot wall, a login redirect. Hard signal: back off."""
29
+ CAPTCHA = "captcha"
30
+ """Never auto-solved. Screenshot, cool down, route around (ORACLES §2)."""
31
+ PARSE_ERROR = "parse_error"
32
+ """The response arrived and made no sense — usually a layout change."""
33
+ EMPTY_ANOMALY = "empty_anomaly"
34
+ """Zero offers where offers were expected. Soft signal: possible soft block."""
35
+ EMPTY_MARKET = "empty_market"
36
+ """Zero offers, believed genuine. Not a failure of the adapter."""
37
+ TIMEOUT = "timeout"
38
+ RATE_LIMITED = "rate_limited"
39
+ """Our own token bucket refused — never the site's problem."""
40
+ UNSUPPORTED = "unsupported"
41
+ """The oracle cannot express this query at all."""
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class OracleCapabilities:
46
+ """What an oracle can be asked, and what asking costs."""
47
+
48
+ trip_shapes: frozenset[TripShape] = frozenset({TripShape.ONE_WAY, TripShape.RETURN})
49
+ date_range_query: bool = False
50
+ """Native calendar/±n pricing — the cheap source of bounds (DESIGN §6)."""
51
+ nearby_airports: bool = False
52
+ virtual_interline: bool = False
53
+ composes_one_ways: bool = False
54
+ """Whether a "return" from this source is already two one-way tickets.
55
+
56
+ AZair's whole business is composing one-ways that no single airline sells
57
+ together, so asking it the return-versus-one-ways question spends two queries to
58
+ be told what its baseline answer already contained. A probe that cannot learn
59
+ anything is not a cheap probe, it is a wasted one (DESIGN §6)."""
60
+ hidden_city: bool = False
61
+ multimodal: bool = False
62
+ baggage_filter: bool = False
63
+ pos_controls: frozenset[str] = frozenset()
64
+ """Any of: market_param, currency_param, locale, ip_sensitive."""
65
+ latency_est_s: float = 3.0
66
+ block_risk: str = "low"
67
+ money_cost_per_query: float = 0.0
68
+ bookable: str = "deep_link"
69
+ coverage: str = "unknown"
70
+ """What this source actually knows about, in plain words.
71
+
72
+ A capability descriptor that only lists *features* will happily send a Bangkok
73
+ query to a European low-cost combinator and read the empty answer as a finding.
74
+ Coverage is a capability too."""
75
+
76
+ def can_answer(self, query: OracleQuery) -> bool:
77
+ return query.trip_shape in self.trip_shapes
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class OracleQuery:
82
+ """One request, in oracle-neutral terms.
83
+
84
+ The `dedup_key` is what makes the politeness budget real: two requests with the
85
+ same key are the same question, and the second one is answered from the session
86
+ store instead of the network (DESIGN §6).
87
+ """
88
+
89
+ origin: str
90
+ destination: str
91
+ depart: date
92
+ trip_shape: TripShape = TripShape.ONE_WAY
93
+ return_on: date | None = None
94
+ adults: int = 1
95
+ cabin: Cabin = Cabin.ECONOMY
96
+ pos: POSContext = field(default_factory=POSContext)
97
+ max_stops: int | None = None
98
+ baggage_filter: bool = False
99
+ depart_until: date | None = None
100
+ """End of the outbound date window, for oracles with `date_range_query`.
101
+
102
+ One query covering a range is the cheapest bound the solver ever gets (DESIGN §6):
103
+ it prices many dates for the cost of one request. Oracles without the capability
104
+ ignore it and are asked per date."""
105
+ return_until: date | None = None
106
+ min_nights: int | None = None
107
+ max_nights: int | None = None
108
+
109
+ def __post_init__(self) -> None:
110
+ if self.trip_shape is TripShape.RETURN and self.return_on is None:
111
+ raise ValueError("a return query needs a return date")
112
+ if self.return_on is not None and self.return_on <= self.depart:
113
+ raise ValueError("the return date must be after the outbound date")
114
+
115
+ @property
116
+ def dedup_key(self) -> str:
117
+ payload = {
118
+ "origin": self.origin.upper(),
119
+ "destination": self.destination.upper(),
120
+ "depart": self.depart.isoformat(),
121
+ "return_on": self.return_on.isoformat() if self.return_on else None,
122
+ "shape": str(self.trip_shape),
123
+ "adults": self.adults,
124
+ "cabin": str(self.cabin),
125
+ "pos": self.pos.model_dump(),
126
+ "max_stops": self.max_stops,
127
+ "baggage_filter": self.baggage_filter,
128
+ "depart_until": self.depart_until.isoformat() if self.depart_until else None,
129
+ "return_until": self.return_until.isoformat() if self.return_until else None,
130
+ "min_nights": self.min_nights,
131
+ "max_nights": self.max_nights,
132
+ }
133
+ blob = json.dumps(payload, sort_keys=True, default=str)
134
+ return hashlib.sha256(blob.encode()).hexdigest()[:24]
135
+
136
+ def describe(self) -> str:
137
+ leg = f"{self.origin}-{self.destination} {self.depart}"
138
+ if self.depart_until and self.depart_until != self.depart:
139
+ leg += f"..{self.depart_until}"
140
+ if self.return_on:
141
+ leg += f" / {self.return_on}"
142
+ if self.return_until and self.return_until != self.return_on:
143
+ leg += f"..{self.return_until}"
144
+ if self.min_nights is not None:
145
+ leg += f" [{self.min_nights}-{self.max_nights}n]"
146
+ return f"{leg} x{self.adults} {self.cabin} @{self.pos.name}"
147
+
148
+
149
+ @dataclass(frozen=True, slots=True)
150
+ class QueryResult:
151
+ """Offers, or a typed reason there are none."""
152
+
153
+ query: OracleQuery
154
+ oracle_id: str
155
+ offers: list[Offer] = field(default_factory=list)
156
+ failure: FailureKind | None = None
157
+ detail: str = ""
158
+ latency_ms: float | None = None
159
+ raw: Any = None
160
+ """Kept only in memory, for fixture recording and debugging."""
161
+
162
+ @property
163
+ def ok(self) -> bool:
164
+ return self.failure is None
165
+
166
+ @property
167
+ def status(self) -> str:
168
+ return "ok" if self.ok else str(self.failure)
169
+
170
+ @classmethod
171
+ def failed(
172
+ cls,
173
+ query: OracleQuery,
174
+ oracle_id: str,
175
+ kind: FailureKind,
176
+ detail: str = "",
177
+ latency_ms: float | None = None,
178
+ ) -> QueryResult:
179
+ return cls(
180
+ query=query,
181
+ oracle_id=oracle_id,
182
+ failure=kind,
183
+ detail=detail,
184
+ latency_ms=latency_ms,
185
+ )
186
+
187
+
188
+ class Oracle(ABC):
189
+ """What every source implements."""
190
+
191
+ id: str
192
+ capabilities: OracleCapabilities
193
+
194
+ @abstractmethod
195
+ def search(self, query: OracleQuery) -> QueryResult:
196
+ """Answer one query, or fail in a way the health monitor can classify."""
197
+
198
+ def canary(self) -> QueryResult:
199
+ """A cheap self-test on a known-liquid route (ORACLES §5).
200
+
201
+ Used to decide recovery after a cooldown: if this comes back empty, the
202
+ adapter is broken rather than the market.
203
+ """
204
+ return self.search(self.canary_query())
205
+
206
+ def canary_query(self) -> OracleQuery:
207
+ from datetime import timedelta
208
+
209
+ return OracleQuery(
210
+ origin="BUD",
211
+ destination="LON",
212
+ depart=date.today() + timedelta(days=30),
213
+ )
214
+
215
+
216
+ def classify_empty(offers: list[Offer], *, expected_liquid: bool) -> FailureKind | None:
217
+ """Zero offers: genuinely empty market, or a silent breakage? (ORACLES §3)
218
+
219
+ The heuristic is deliberately conservative — on a route that should always have
220
+ something, "no results" means the adapter, not the world.
221
+ """
222
+ if offers:
223
+ return None
224
+ return FailureKind.EMPTY_ANOMALY if expected_liquid else FailureKind.EMPTY_MARKET
@@ -0,0 +1,199 @@
1
+ """Reusing a consent choice you already made (ORACLES §2, §7).
2
+
3
+ From an EU address Google answers with a consent interstitial, and an HTTP client
4
+ cannot answer it. The design's answer is a browser profile that has *actually* clicked
5
+ the dialog — this module is the small version of that: you make the choice in your own
6
+ browser, once, and layover reuses the resulting cookie the way your browser would.
7
+
8
+ **What this deliberately is not.** It does not fabricate a consent value, guess one,
9
+ or synthesise "accept all" on your behalf. The value comes from a browser where a
10
+ human answered the dialog; if you have not answered it, there is nothing to import and
11
+ the wall stays up. That distinction is the whole point: the EU obstacle is a consent
12
+ question, and the honest way past a question is to answer it.
13
+
14
+ It also does not need to be "accept all". Google records a rejection in the same
15
+ cookie, and a rejected-consent cookie clears the interstitial just as well.
16
+
17
+ Capture it from a **logged-out private window**, not from your everyday browser. The
18
+ value is a stable token, so every request carries the same one; taken from a session
19
+ where you are signed in, it is a token your signed-in browser also holds, and the two
20
+ become correlatable. An incognito window that has never seen your account gives a
21
+ cookie that is merely stable, not linked to you. (A *new Google account* is the wrong
22
+ fix — an account is a far stronger identifier than a cookie, and creating one to
23
+ support automated querying turns a grey area into a plain violation.)
24
+
25
+ "Reject all" works as well as "accept all": both record a decision, and the decision
26
+ is what clears the interstitial.
27
+
28
+ open a private window → google.com → answer the dialog → DevTools →
29
+ Application → Cookies → copy **SOCS**
30
+
31
+ layover consent --import "SOCS=<value>"
32
+ layover consent --check
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import tomllib
38
+ from dataclasses import dataclass, field
39
+ from datetime import UTC, datetime
40
+ from pathlib import Path
41
+
42
+ CONSENT_COOKIES = ("SOCS", "CONSENT")
43
+ """The cookies Google uses to record the answer. Nothing else is stored."""
44
+
45
+
46
+ def default_cookie_path() -> Path:
47
+ return Path.home() / ".config" / "layover" / "cookies.toml"
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class CookieJar:
52
+ """Per-domain cookies the owner has chosen to share with layover.
53
+
54
+ Kept in its own file, gitignored, and holding only consent cookies — never
55
+ session or authentication cookies. layover logs into nothing (ORACLES §7), so a
56
+ cookie that would authenticate anything has no business here.
57
+ """
58
+
59
+ path: Path = field(default_factory=default_cookie_path)
60
+ domains: dict[str, dict[str, str]] = field(default_factory=dict)
61
+ meta: dict[str, dict[str, str]] = field(default_factory=dict)
62
+ """Per domain: when it was imported, and which exit it was captured from.
63
+
64
+ The exit matters more than it looks. A cookie captured at home and then sent
65
+ through a Singapore exit is a *more* distinctive combination than either alone —
66
+ it says "the person whose consent record this is, arriving from an address they
67
+ have never used". That is the identity-consistency rule in DESIGN §9, and here it
68
+ is enforced rather than advised."""
69
+
70
+ @classmethod
71
+ def load(cls, path: Path | None = None) -> CookieJar:
72
+ path = path or default_cookie_path()
73
+ jar = cls(path=path)
74
+ if not path.is_file():
75
+ return jar
76
+ raw = tomllib.loads(path.read_text())
77
+ for domain, cookies in (raw.get("domain") or {}).items():
78
+ jar.domains[domain] = {str(k): str(v) for k, v in cookies.items()}
79
+ for domain, meta in (raw.get("meta") or {}).items():
80
+ jar.meta[domain] = {str(k): str(v) for k, v in meta.items()}
81
+ return jar
82
+
83
+ def save(self) -> None:
84
+ self.path.parent.mkdir(parents=True, exist_ok=True)
85
+ lines = [
86
+ "# Cookies you have chosen to share with layover (docs/ORACLES.md §2).",
87
+ "#",
88
+ "# Consent choices only — never anything that authenticates you. layover",
89
+ "# logs into nothing, so a session cookie here would be pure risk.",
90
+ "# This file is gitignored. Delete it any time; the only cost is that",
91
+ "# Google goes back to asking.",
92
+ "",
93
+ ]
94
+ for domain, cookies in sorted(self.domains.items()):
95
+ lines.append(f'[domain."{domain}"]')
96
+ for name, value in sorted(cookies.items()):
97
+ lines.append(f'{name} = "{value}"')
98
+ lines.append("")
99
+ for domain, meta in sorted(self.meta.items()):
100
+ lines.append(f'[meta."{domain}"]')
101
+ for name, value in sorted(meta.items()):
102
+ lines.append(f'{name} = "{value}"')
103
+ lines.append("")
104
+ self.path.write_text("\n".join(lines))
105
+ self.path.chmod(0o600)
106
+
107
+ def for_domain(self, domain: str) -> dict[str, str]:
108
+ """Cookies for a host, including any stored against a parent domain."""
109
+ out: dict[str, str] = {}
110
+ for stored, cookies in self.domains.items():
111
+ if domain == stored or domain.endswith(f".{stored}"):
112
+ out.update(cookies)
113
+ return out
114
+
115
+ def set(
116
+ self,
117
+ domain: str,
118
+ name: str,
119
+ value: str,
120
+ *,
121
+ captured_egress: str | None = None,
122
+ ) -> None:
123
+ self.domains.setdefault(domain, {})[name] = value
124
+ meta = self.meta.setdefault(domain, {})
125
+ meta["imported_at"] = datetime.now(UTC).isoformat(timespec="seconds")
126
+ if captured_egress:
127
+ meta["captured_egress"] = captured_egress
128
+
129
+ def captured_egress(self, domain: str) -> str | None:
130
+ for stored, meta in self.meta.items():
131
+ if domain == stored or domain.endswith(f".{stored}"):
132
+ return meta.get("captured_egress")
133
+ return None
134
+
135
+ def age_days(self, domain: str) -> int | None:
136
+ """How old the stored answer is. Consent records do not live forever."""
137
+ for stored, meta in self.meta.items():
138
+ if domain == stored or domain.endswith(f".{stored}"):
139
+ stamp = meta.get("imported_at")
140
+ if not stamp:
141
+ return None
142
+ return (datetime.now(UTC) - datetime.fromisoformat(stamp)).days
143
+ return None
144
+
145
+ def usable_for(
146
+ self, domain: str, *, egress: str | None, max_age_days: int = 365
147
+ ) -> tuple[dict[str, str], list[str]]:
148
+ """Cookies to send, plus every reason not to trust them.
149
+
150
+ Returns no cookies at all on an egress mismatch. Sending them anyway would
151
+ trade a consent wall — a visible, recoverable failure — for a distinctive
152
+ fingerprint, which is neither.
153
+ """
154
+ cookies = self.for_domain(domain)
155
+ if not cookies:
156
+ return {}, []
157
+
158
+ problems: list[str] = []
159
+ captured = self.captured_egress(domain)
160
+ if captured and egress and captured != egress:
161
+ problems.append(
162
+ f"captured from {captured} but this request leaves from {egress} — "
163
+ f"not sending it: that pairing is more identifying than either alone"
164
+ )
165
+ return {}, problems
166
+
167
+ age = self.age_days(domain)
168
+ if age is not None and age > max_age_days:
169
+ problems.append(
170
+ f"imported {age} days ago; consent records typically lapse around a "
171
+ f"year, so re-import if the wall comes back"
172
+ )
173
+ return cookies, problems
174
+
175
+
176
+ class NotAConsentCookie(ValueError):
177
+ """Refuses anything that is not a consent cookie."""
178
+
179
+
180
+ def parse_cookie(text: str) -> tuple[str, str]:
181
+ """Read a `NAME=value` pair, and accept only consent cookies.
182
+
183
+ The refusal matters more than the parsing: pasting a whole `Cookie:` header from a
184
+ logged-in browser would hand over session credentials, and this tool has no use
185
+ for them.
186
+ """
187
+ if "=" not in text:
188
+ raise NotAConsentCookie(f"expected NAME=value, got {text[:40]!r}")
189
+ name, _, value = text.strip().partition("=")
190
+ name, value = name.strip(), value.strip().strip('"')
191
+ if name.upper() not in CONSENT_COOKIES:
192
+ raise NotAConsentCookie(
193
+ f"{name} is not a consent cookie. Only {', '.join(CONSENT_COOKIES)} are "
194
+ f"accepted — layover logs into nothing and must not hold anything that "
195
+ f"authenticates you."
196
+ )
197
+ if not value:
198
+ raise NotAConsentCookie(f"{name} has no value")
199
+ return name.upper(), value
@@ -0,0 +1,208 @@
1
+ """What we have learned about oracles, kept across sessions (DESIGN §5).
2
+
3
+ Session stores age out after a fortnight, which is right for prices and wrong for
4
+ everything else. Two kinds of fact outlive them:
5
+
6
+ * **Coverage.** AZair does not fly to Bangkok. That is a property of the oracle, not
7
+ of a search, and re-learning it costs a query every time a session starts fresh.
8
+ * **Health.** Backoff is meaningless if it forgets: an oracle that blocked us an hour
9
+ ago should still be cooling now, whatever process is asking (ORACLES §5).
10
+
11
+ Both are deliberately *small* and *non-price*. Nothing here records a fare, so the
12
+ no-hoarding principle is untouched — this is a notebook about the sources, not about
13
+ the market.
14
+
15
+ Coverage facts expire. Airlines add routes and sites extend their networks, so a
16
+ "not covered" from six months ago is a stale opinion rather than a fact; the default
17
+ shelf life is 90 days, after which the question is worth one query again.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from dataclasses import dataclass
24
+ from datetime import UTC, datetime, timedelta
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ DEFAULT_COVERAGE_TTL_DAYS = 90
29
+
30
+
31
+ def default_path() -> Path:
32
+ return Path.home() / ".local" / "state" / "layover" / "oracle-knowledge.json"
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class CoverageFact:
37
+ oracle_id: str
38
+ route: str
39
+ covered: bool
40
+ learned_at: datetime
41
+ detail: str = ""
42
+
43
+ def age_days(self, now: datetime | None = None) -> int:
44
+ return ((now or datetime.now(UTC)) - self.learned_at).days
45
+
46
+ def is_stale(
47
+ self, ttl_days: int = DEFAULT_COVERAGE_TTL_DAYS, now: datetime | None = None
48
+ ) -> bool:
49
+ return self.age_days(now) >= ttl_days
50
+
51
+ def describe(self) -> str:
52
+ verdict = "covers" if self.covered else "does not cover"
53
+ return f"{self.oracle_id} {verdict} {self.route} (learned {self.age_days()}d ago)"
54
+
55
+
56
+ class OracleKnowledge:
57
+ """A small, durable notebook about the sources."""
58
+
59
+ def __init__(
60
+ self, path: Path | None = None, *, ttl_days: int = DEFAULT_COVERAGE_TTL_DAYS
61
+ ) -> None:
62
+ self.path = Path(path or default_path())
63
+ self.ttl_days = ttl_days
64
+ self._data: dict[str, Any] = self._load()
65
+
66
+ def _load(self) -> dict[str, Any]:
67
+ if not self.path.is_file():
68
+ return {"coverage": {}, "health": {}}
69
+ try:
70
+ raw = json.loads(self.path.read_text())
71
+ except json.JSONDecodeError:
72
+ return {"coverage": {}, "health": {}} # corrupt notebook: start a new one
73
+ raw.setdefault("coverage", {})
74
+ raw.setdefault("health", {})
75
+ return raw
76
+
77
+ def save(self) -> None:
78
+ self.path.parent.mkdir(parents=True, exist_ok=True)
79
+ self.path.write_text(json.dumps(self._data, indent=2, sort_keys=True))
80
+
81
+ # ── coverage ─────────────────────────────────────────────────────────────
82
+
83
+ @staticmethod
84
+ def _key(oracle_id: str, route: str) -> str:
85
+ return f"{oracle_id}|{route.upper()}"
86
+
87
+ def record_coverage(
88
+ self,
89
+ oracle_id: str,
90
+ route: str,
91
+ *,
92
+ covered: bool,
93
+ detail: str = "",
94
+ when: datetime | None = None,
95
+ ) -> None:
96
+ """Remember whether a source can answer about a route at all."""
97
+ self._data["coverage"][self._key(oracle_id, route)] = {
98
+ "oracle_id": oracle_id,
99
+ "route": route.upper(),
100
+ "covered": covered,
101
+ "learned_at": (when or datetime.now(UTC)).isoformat(timespec="seconds"),
102
+ "detail": detail[:200],
103
+ }
104
+ self.save()
105
+
106
+ def coverage(self, oracle_id: str, route: str) -> CoverageFact | None:
107
+ entry = self._data["coverage"].get(self._key(oracle_id, route))
108
+ if not entry:
109
+ return None
110
+ try:
111
+ learned = datetime.fromisoformat(entry["learned_at"])
112
+ except (KeyError, ValueError):
113
+ return None
114
+ return CoverageFact(
115
+ oracle_id=entry["oracle_id"],
116
+ route=entry["route"],
117
+ covered=bool(entry["covered"]),
118
+ learned_at=learned,
119
+ detail=entry.get("detail", ""),
120
+ )
121
+
122
+ def excluded_for(self, route: str, *, now: datetime | None = None) -> set[str]:
123
+ """Oracles known not to cover this route, ignoring stale opinions."""
124
+ out: set[str] = set()
125
+ for entry in self._data["coverage"].values():
126
+ if entry.get("route") != route.upper() or entry.get("covered"):
127
+ continue
128
+ fact = self.coverage(entry["oracle_id"], entry["route"])
129
+ if fact and not fact.is_stale(self.ttl_days, now):
130
+ out.add(fact.oracle_id)
131
+ return out
132
+
133
+ def known_gaps(self) -> list[CoverageFact]:
134
+ facts = []
135
+ for entry in self._data["coverage"].values():
136
+ fact = self.coverage(entry.get("oracle_id", ""), entry.get("route", ""))
137
+ if fact and not fact.covered and not fact.is_stale(self.ttl_days):
138
+ facts.append(fact)
139
+ return sorted(facts, key=lambda f: (f.oracle_id, f.route))
140
+
141
+ # ── health ───────────────────────────────────────────────────────────────
142
+
143
+ def record_failure(
144
+ self,
145
+ oracle_id: str,
146
+ kind: str,
147
+ *,
148
+ cooldown_minutes: float = 0.0,
149
+ when: datetime | None = None,
150
+ ) -> None:
151
+ """Note a failure, and when this oracle may be asked again.
152
+
153
+ Backoff that forgets is not backoff: an oracle blocked ten minutes ago is
154
+ still blocked now, whichever process is asking.
155
+ """
156
+ now = when or datetime.now(UTC)
157
+ entry = self._data["health"].get(oracle_id, {})
158
+ entry["last_failure"] = kind
159
+ entry["last_failure_at"] = now.isoformat(timespec="seconds")
160
+ entry["consecutive_failures"] = int(entry.get("consecutive_failures", 0)) + 1
161
+ if cooldown_minutes > 0:
162
+ entry["cooling_until"] = (now + timedelta(minutes=cooldown_minutes)).isoformat(
163
+ timespec="seconds"
164
+ )
165
+ self._data["health"][oracle_id] = entry
166
+ self.save()
167
+
168
+ def record_success(self, oracle_id: str, when: datetime | None = None) -> None:
169
+ entry = self._data["health"].get(oracle_id, {})
170
+ entry["consecutive_failures"] = 0
171
+ entry["cooling_until"] = None
172
+ entry["last_success_at"] = (when or datetime.now(UTC)).isoformat(timespec="seconds")
173
+ self._data["health"][oracle_id] = entry
174
+ self.save()
175
+
176
+ def cooling(self, oracle_id: str, *, now: datetime | None = None) -> datetime | None:
177
+ """When this oracle may be asked again, or `None` if it is free to use."""
178
+ entry = self._data["health"].get(oracle_id) or {}
179
+ stamp = entry.get("cooling_until")
180
+ if not stamp:
181
+ return None
182
+ try:
183
+ until = datetime.fromisoformat(stamp)
184
+ except ValueError:
185
+ return None
186
+ return until if until > (now or datetime.now(UTC)) else None
187
+
188
+ def health(self, oracle_id: str) -> dict[str, Any]:
189
+ return dict(self._data["health"].get(oracle_id) or {})
190
+
191
+ # ── maintenance ──────────────────────────────────────────────────────────
192
+
193
+ def forget(self, oracle_id: str) -> int:
194
+ """Drop everything remembered about one source; returns how much was dropped.
195
+
196
+ The escape hatch for when the notebook is wrong — a fixed adapter, a site that
197
+ extended its network, a cooldown that has clearly outlived its cause.
198
+ """
199
+ keys = [k for k, v in self._data["coverage"].items() if v.get("oracle_id") == oracle_id]
200
+ for key in keys:
201
+ del self._data["coverage"][key]
202
+ dropped = len(keys) + (1 if self._data["health"].pop(oracle_id, None) else 0)
203
+ self.save()
204
+ return dropped
205
+
206
+ def oracles(self) -> list[str]:
207
+ names = {entry.get("oracle_id", "") for entry in self._data["coverage"].values()}
208
+ return sorted(n for n in names | set(self._data["health"]) if n)