murtaza-2007 commited on
Commit
2bd66df
·
0 Parent(s):

Initial commit

Browse files
Files changed (17) hide show
  1. .gitignore +5 -0
  2. CLAUDE.md +252 -0
  3. LOGO.png +0 -0
  4. LOGOnobg.png +0 -0
  5. app.js +1344 -0
  6. common_wiki_searches.txt +556 -0
  7. config.py +94 -0
  8. embedding.py +138 -0
  9. htmlbackup.txt +0 -0
  10. index.html +498 -0
  11. main.py +34 -0
  12. mainpybackup.txt +799 -0
  13. requirements.txt +5 -0
  14. search.py +569 -0
  15. server.py +126 -0
  16. styles.css +2358 -0
  17. wiki.py +313 -0
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.bak
4
+ .claude/
5
+ .env
CLAUDE.md ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Running the app
6
+
7
+ ```bash
8
+ # Start the backend (from WikiNav/)
9
+ uvicorn main:app --reload --port 8000
10
+
11
+ # The frontend is a single static page split across index.html / styles.css /
12
+ # app.js — open index.html directly in a browser, or serve it alongside the
13
+ # backend (FastAPI does not serve static files here; open file:// or use a
14
+ # simple HTTP server on the same port via a proxy).
15
+ ```
16
+
17
+ No external services are required at runtime. The embedding model
18
+ (`sentence-transformers`, `all-MiniLM-L6-v2`) is loaded once, in-process,
19
+ at FastAPI startup — there is no LLM, no Ollama, and no other network
20
+ dependency besides the Wikipedia API itself.
21
+
22
+ ## Architecture
23
+
24
+ Backend is split into focused modules; frontend is markup + CSS + JS in
25
+ separate files.
26
+
27
+ ### Backend modules
28
+
29
+ - **`main.py`** — thin entrypoint only: the Windows stdout/stderr UTF-8
30
+ reconfigure (must run before any import that logs non-ASCII arrows) and
31
+ the uvicorn launch. Reads `$PORT` for PaaS hosts (Render etc.), falls
32
+ back to 8000 locally.
33
+ - **`config.py`** — every tunable constant, env-overridable where it
34
+ matters (embedding model/device/batch size). Single source of truth —
35
+ nothing in search/embedding/wiki should hardcode a magic number that
36
+ belongs here.
37
+ - **`server.py`** — the FastAPI `app`: CORS, the startup handler
38
+ (`embedding.load_model()`), `GET /api/random`, `GET /api/health`, and the
39
+ `/ws` WebSocket endpoint. One `AStarNavigator` instance per connection. A
40
+ parallel `listen_controls()` coroutine on the same socket handles
41
+ `{control: "pause"}` / `{control: "resume"}` messages while the search
42
+ coroutine runs.
43
+ - **`search.py`** — `AStarNavigator` + `SearchStats`: the whole search
44
+ pipeline (scoring, pruning, the meeting check, the regression fence).
45
+ - **`wiki.py`** — all Wikipedia API access: `resolve_article_title`,
46
+ `get_all_links`, `get_backlinks`, `get_article_summary`,
47
+ `check_disambiguation`, junk/date filters, seed-list loading for the
48
+ Random button.
49
+ - **`embedding.py`** — the `sentence-transformers` model singleton,
50
+ `batch_embed`, `cosine_similarity`, and the per-session caches
51
+ (`_emb_cache`, `_dead_ends`, reset via `reset_session_caches()`).
52
+
53
+ **WebSocket `/ws`**: the frontend sends `{start, end}` once; the backend
54
+ streams JSON events (`status`, `resolved`, `node_add`, `expand_node`,
55
+ `node_state`, `neighbours`, `found`, `not_found`) as the search progresses.
56
+
57
+ ### `AStarNavigator` (search.py) — honest bidirectional meeting-check search
58
+
59
+ Despite the class name it runs **greedy best-first search, not A***. Core
60
+ pipeline per expansion:
61
+ 1. **Goal-zone expansion** (every `GOAL_ZONE_EXPAND_INTERVAL=10` steps):
62
+ expand `GOAL_ZONE_EXPAND_BATCH=5` unexpanded d1 members by fetching
63
+ their backlinks, creating a depth-2 goal zone (`_goal_zone_d2`) with
64
+ bridge bookkeeping (`d2_node → d1_bridge` mapping).
65
+ 2. Fetch all links from the current article (`get_all_links`, up to
66
+ `MAX_FETCH=500`, redirect-resolved to canonical titles via
67
+ `generator=links&redirects=1`). The same call also returns each linked
68
+ page's Wikidata short description (`pageprops=wikibase-shortdesc`,
69
+ bundled into the same request — no extra round-trip).
70
+ 3. **Immediate-win check**: if the target is directly in the link set,
71
+ emit `found` right away.
72
+ 4. **D1 meeting check**: if any neighbour is a member of the target's
73
+ depth-1 backlink set (`_goal_zone_d1`), that neighbour is a verified
74
+ bridge — `current → bridge → target` is a fully real two-edge path.
75
+ 5. **D2 meeting check**: if any neighbour is in `_goal_zone_d2`, then
76
+ `current → d2_node → d1_bridge → target` is a verified three-edge
77
+ path (d2_node links to d1_bridge by construction, d1_bridge links to
78
+ target by construction). All three edges recorded via `_link()`.
79
+ 6. **Stagnation detection**: if the best h-value hasn't improved by
80
+ `STAGNATION_DELTA=0.02` over a `STAGNATION_WINDOW=8`-step sliding
81
+ window, the search is stagnant. When stagnant: beam widens from 15 to
82
+ `PRUNE_TOP_K_STAGNANT=25`, and a diversity bonus
83
+ (`DIVERSITY_WEIGHT=0.10 × distance from cluster centroid`) is added to
84
+ each candidate's score to escape dense off-topic clusters.
85
+ 7. Batch-embed uncached candidates in one in-process `sentence-transformers`
86
+ call (`batch_embed`, run off the event loop via a thread executor).
87
+ Candidates are embedded as `"{title}. {wikidata_short_desc}"`, not a
88
+ bare title — see "Semantic drift" below for why.
89
+ 8. Score each candidate (`_rank_candidates`):
90
+ `cosine(candidate, target) + frontier_bonus + diversity_bonus`.
91
+ 9. Prune to the top `PRUNE_TOP_K=15` (or 25 when stagnant), dropping
92
+ anything below the raw-cosine floor `PRUNE_FLOOR=0.15` (goal-zone d1
93
+ and d2 members exempt).
94
+ 10. Push survivors to the heap.
95
+
96
+ **Heap priority = `h + DEPTH_TIEBREAK_EPSILON * g`** (config.py,
97
+ `DEPTH_TIEBREAK_EPSILON = 0.01`). This is a deliberate reversal of an
98
+ earlier "pure-h, no g-cost" design — see "Semantic drift" below. The
99
+ penalty is small (max 0.6 at `MAX_HOPS=60`); it only breaks ties/near-ties,
100
+ it doesn't override a genuinely strong `h` advantage.
101
+
102
+ **Disambiguation detection**: before the search begins, the resolved
103
+ target is checked for `pageprops.disambiguation`. If it's a disambiguation
104
+ page (e.g. "A*" with only 3 backlinks), its outbound links are embedded
105
+ and compared against the user's query (with a title-prefix affinity bonus)
106
+ to auto-redirect to the most relevant real article (e.g. "A* search
107
+ algorithm" with 112 backlinks). Uses `check_disambiguation()` in wiki.py.
108
+
109
+ **Scoring formula** (`_rank_candidates`, no LLM involved anywhere):
110
+ - `cosine(candidate_embedding, target_embedding)` — primary signal, raw
111
+ value stashed for the prune floor
112
+ - `+0.30` if the candidate is in `_goal_zone_d1` (`FRONTIER_D1_BONUS`)
113
+ - `+0.15` if the candidate is in `_goal_zone_d2` (`FRONTIER_D2_BONUS`)
114
+ - `+DIVERSITY_WEIGHT × (1 - cosine(candidate, centroid))` when stagnant
115
+
116
+ **Target goal zone** (the "bidirectional" part): at search init, the
117
+ target's depth-1 backlinks are fetched and stored as `_goal_zone_d1`.
118
+ Every `GOAL_ZONE_EXPAND_INTERVAL` steps, `_expand_goal_zone()` fetches
119
+ backlinks of unexpanded d1 members, populating `_goal_zone_d2` (a dict
120
+ mapping `d2_node → d1_bridge`). This creates a growing catchment area
121
+ that makes the meeting check increasingly likely to fire, especially for
122
+ targets with few initial backlinks.
123
+
124
+ **Regression fence**: every `came_from` pointer is recorded through
125
+ `_link()`, which also adds the edge to `self._real_edges`. `_emit_found()`
126
+ calls `_validate_path()` before ever sending a `found` event, refusing to
127
+ emit any path containing an edge that wasn't observed as a real Wikipedia
128
+ link. This guards against ever again fabricating a path (see "History"
129
+ below).
130
+
131
+ **Session caches** (`_emb_cache`, `_dead_ends`) are module-level dicts in
132
+ `embedding.py`, reset at the start of each search via
133
+ `reset_session_caches()`. `search.py` accesses them as
134
+ `_embedding_mod._emb_cache` / `_embedding_mod._dead_ends` (module-qualified,
135
+ never imported by name) since `reset_session_caches()` rebinds both names
136
+ to fresh objects every search — a `from embedding import _emb_cache` would
137
+ capture the old object and go stale after the first reset.
138
+
139
+ **REST endpoints**:
140
+ - `GET /api/random` — random pair from `common_wiki_searches.txt`, used by
141
+ the "Random" button
142
+ - `GET /api/health` — polled by the frontend's loading screen; a successful
143
+ response implies the embedding model finished loading at startup
144
+
145
+ Autocomplete has no backend involvement — the frontend calls Wikipedia's
146
+ `opensearch` API directly.
147
+
148
+ ### Frontend: `index.html` + `styles.css` + `app.js`
149
+
150
+ `index.html` is markup only; all CSS lives in `styles.css`, all JS in
151
+ `app.js` (single non-module `<script src>`, so handlers referenced by
152
+ inline `onclick=` attributes must stay plain function declarations on
153
+ `window`, not module exports).
154
+
155
+ Key `app.js` globals:
156
+ - `AC_DEBOUNCE_MS = 80` — autocomplete debounce
157
+ - `triggerAutocomplete()` — calls Wikipedia's opensearch API directly (CORS-enabled, no backend round-trip)
158
+ - `renderAcDropdown()` — builds the suggestion list; uses inline SVG icons (not emoji)
159
+ - `connectWS()` — opens the WebSocket, sends `{start, end}`, dispatches incoming events to render functions
160
+ - `playHeroEntrance()` — shared landing-page entrance sequence, called from both the initial load and `resetAll()`
161
+ - About modal opened by `openAboutModal()` / closed by `closeAboutModal()`
162
+
163
+ **Title entrance animation** (`#hero-title.entrance`, `styles.css`): a
164
+ single CSS keyframe animation (`@keyframes title-entrance`) takes the title
165
+ from its landing position, grows it to viewport center, holds, then
166
+ returns it to the landing position as the rest of the hero fades in.
167
+ Total duration and the hold length are both encoded in the keyframe
168
+ percentages — to change the hold duration, adjust both the animation
169
+ duration on `#hero-title.entrance` and the keyframe percentages together
170
+ (they're coupled: a percentage is a fraction of the total duration).
171
+
172
+ **Fonts**: Cinzel (Google Fonts, for "AURELIUS" title and topbar) + Outfit (body) + DM Mono (data/scores). All loaded via `<link>` in `<head>`.
173
+
174
+ ## Key constants (config.py)
175
+
176
+ | Constant | Value | Purpose |
177
+ |---|---|---|
178
+ | `MAX_HOPS` | 60 | Expansion-count cap |
179
+ | `PRUNE_TOP_K` | 15 | Candidates kept per expansion after scoring |
180
+ | `PRUNE_FLOOR` | 0.15 | Soft floor on raw cosine-to-target (top-5 + goal-zone members exempt) |
181
+ | `PRUNE_MIN_SURVIVORS` | 5 | Always keep at least this many top-ranked candidates even below floor |
182
+ | `FRONTIER_D1_BONUS` | 0.30 | Score bonus for depth-1 goal-zone membership |
183
+ | `FRONTIER_D2_BONUS` | 0.15 | Score bonus for depth-2 goal-zone membership |
184
+ | `DEPTH_TIEBREAK_EPSILON` | 0.01 | Heap priority = h + this × g (see "Semantic drift" below) |
185
+ | `GOAL_ZONE_EXPAND_INTERVAL` | 10 | Expand goal zone every N steps |
186
+ | `GOAL_ZONE_EXPAND_BATCH` | 5 | D1 members expanded per interval |
187
+ | `GOAL_ZONE_D2_BACKLINK_LIMIT` | 100 | Max backlinks fetched per d1 member |
188
+ | `STAGNATION_WINDOW` | 8 | Sliding window for h-improvement tracking |
189
+ | `STAGNATION_DELTA` | 0.02 | Min improvement to count as progress |
190
+ | `PRUNE_TOP_K_STAGNANT` | 25 | Widened beam when stagnant |
191
+ | `DIVERSITY_WEIGHT` | 0.10 | Centroid-distance diversity bonus when stagnant |
192
+ | `EMBED_BATCH_SIZE` | 64 | Titles per in-process `model.encode()` batch |
193
+ | `MAX_FETCH` | 500 | Max links fetched per article |
194
+ | `MAX_PAGES` | 6 | Safety cap on pagination pages when hunting a specific goal link |
195
+
196
+ ## Things to know
197
+
198
+ - `_is_junk()` / `_JUNK_TITLES` (wiki.py) filter Wikipedia maintenance articles, citation-identifier stubs, disambiguation pages, date articles, and list articles before they enter the heap.
199
+ - The embedding model (`all-MiniLM-L6-v2`, 384-dim) is loaded once at startup and reused for every embed call. There is no separate autocomplete model and no HNSW index.
200
+ - `WIKI_HEADERS["User-Agent"]` must include contact info or Wikimedia's robot policy 403s the request — see config.py.
201
+ - `mainpybackup.txt`, `htmlbackup.txt`, `main.py.bak`, `index.html.bak` are manual snapshots — not used by the app.
202
+
203
+ ## History: incidents worth knowing about
204
+
205
+ - **Fabricated-path bug (fixed)**: an earlier stagnation-detection +
206
+ frontier-jump escape valve set `came_from` pointers with no real
207
+ Wikipedia link behind them (verified live: the jump target had no real
208
+ link from the node it claimed to jump from). Removed entirely and
209
+ replaced with the meeting-check architecture described above, plus the
210
+ permanent `_real_edges` / `_validate_path` regression fence.
211
+ - **Semantic drift ("Anu → Iteration" investigation, fixed)**: a path
212
+ through several Egyptian/Mesopotamian deity pages looked fabricated —
213
+ `Anu`'s rendered page has no visible link reading "Iteration" — but
214
+ verified real against the live API: it's a piped wikilink
215
+ `[[Iteration|iterative]]` in a sentence about deity-name etymology,
216
+ invisible to a manual Ctrl+F but a genuine edge. The actual problem was
217
+ the search wandering through several pages with no real relevance to the
218
+ target before reaching it. Root cause: candidates were embedded as bare
219
+ titles, giving the model no domain signal for short/ambiguous proper
220
+ nouns. Fixed by (1) embedding `"{title}. {wikidata_short_desc}"` instead
221
+ of a bare title (the short description is fetched for free, bundled into
222
+ the existing `get_all_links` request via `pageprops=wikibase-shortdesc`),
223
+ and (2) adding `DEPTH_TIEBREAK_EPSILON` to heap priority so one noisy
224
+ candidate can't drag the search arbitrarily deep into an irrelevant
225
+ cluster with no incentive to prefer a shallower alternative. This is a
226
+ deliberate, intentional reversal of the original "heap priority = h only,
227
+ no g-cost" design — if you're tempted to "simplify" the heap key back to
228
+ pure `h`, don't; that's what caused the drift.
229
+ - **"A\*" disambiguation failure (fixed, v3)**: searching for "A*" as a
230
+ target resolved to a Wikipedia disambiguation page with only 3 backlinks,
231
+ making it nearly unreachable (60 steps, NOT_FOUND). The real algorithm
232
+ article "A* search algorithm" has 112 backlinks. Fixed by adding
233
+ disambiguation detection (`check_disambiguation` in wiki.py) + automatic
234
+ redirect to the best semantic match among the disambig page's outbound
235
+ links (with a title-prefix affinity bonus for short queries). Also added
236
+ active backward expansion (depth-2 goal zone with bridge bookkeeping) and
237
+ stagnation-based cluster escape (beam widening + diversity bonus) to
238
+ prevent similar structural failures.
239
+ - **Prune-floor total wipe-out (fixed, "Cleopatra → Time complexity"
240
+ incident)**: the search expanded Cleopatra once (step 1), pruned ALL
241
+ 500 candidates below `PRUNE_FLOOR=0.15` (raw cosine of ancient-history
242
+ links against a CS target is ~0.05–0.12), the heap went empty, and the
243
+ search reported NOT_FOUND after 1 step. Root cause: `PRUNE_FLOOR` was a
244
+ hard kill switch with no safety net — if *every* candidate from an
245
+ expansion fell below the floor, zero survivors meant immediate death
246
+ regardless of `MAX_HOPS`. The same bug class existed for the earlier
247
+ 0.22 threshold (documented in config.py), but at 0.15 it only surfaced
248
+ for maximally distant topic pairs. Fixed by adding
249
+ `PRUNE_MIN_SURVIVORS=5`: the top 5 candidates by combined score always
250
+ survive regardless of the floor, so the search can always make progress.
251
+ The floor still filters positions 6+ in the ranked list, keeping its
252
+ original role of trimming low-quality noise.
LOGO.png ADDED
LOGOnobg.png ADDED
app.js ADDED
@@ -0,0 +1,1344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ── State ────────────────────────────────────────────────────────────────────
2
+ let ws = null;
3
+ let nodes = {};
4
+ let edges = [];
5
+ let centreNode = null;
6
+ let pathNodes = new Set();
7
+ let simulation = null;
8
+ let svgEl, gEl;
9
+ let width, height;
10
+ let running = false;
11
+ let _searchGen = 0;
12
+ let scoreCards = {};
13
+ let heroDismissed = false;
14
+ let paused = false;
15
+ let foundPath = [];
16
+ let _lastElapsedS = 0;
17
+
18
+ // ── NEW: autocomplete state (vector search) ───────────────────────────────────
19
+ let acDebounceTimers = { start: null, end: null }; // per-field debounce handles
20
+ let acActiveIndex = { start: -1, end: -1 }; // keyboard-highlighted row
21
+ let acLastResults = { start: [], end: [] }; // last suggestions per field
22
+ const AC_DEBOUNCE_MS = 80; // tight debounce — feel instant, but avoid a request on every single keypress
23
+
24
+ // ── Rotating placeholder examples (hero inputs) ────────────────────────────────
25
+ // Nagpur → Mars is first (shown on launch) and held for 2s; every other pair
26
+ // uses the default 1.3s. Per-pair `duration` overrides DEFAULT_PLACEHOLDER_MS;
27
+ // a setTimeout chain (not setInterval) is what makes a per-pair duration
28
+ // possible — a single fixed-rate interval couldn't give one entry a different
29
+ // dwell time than the rest.
30
+ const PLACEHOLDER_PAIRS = [
31
+ { pair: ['Nagpur', 'Mars'], duration: 2000 },
32
+ { pair: ['Alexander the Great', 'Jazz music'] },
33
+ { pair: ['Pizza', 'Mount Everest'] },
34
+ { pair: ['Albert Einstein', 'Coffee'] },
35
+ { pair: ['Leonardo da Vinci', 'The Internet'] },
36
+ { pair: ['Cleopatra', 'Bitcoin'] },
37
+ { pair: ['Shakespeare', 'Video games'] },
38
+ { pair: ['Mahatma Gandhi', 'Formula 1'] },
39
+ { pair: ['Vincent van Gogh', 'Climate change'] },
40
+ { pair: ['Genghis Khan', 'Sushi'] },
41
+ { pair: ['The Moon', 'Chess'] },
42
+ ];
43
+ const DEFAULT_PLACEHOLDER_MS = 1300;
44
+ let _placeholderIdx = 0;
45
+ let _placeholderTimer = null;
46
+
47
+ function _applyPlaceholder() {
48
+ const startEl = document.getElementById('hero-inp-start');
49
+ const endEl = document.getElementById('hero-inp-end');
50
+ if (!startEl || !endEl) return;
51
+ const [s, e] = PLACEHOLDER_PAIRS[_placeholderIdx].pair;
52
+ startEl.placeholder = `e.g. ${s}`;
53
+ endEl.placeholder = `e.g. ${e}`;
54
+ }
55
+
56
+ function _scheduleNextPlaceholder() {
57
+ const duration = PLACEHOLDER_PAIRS[_placeholderIdx].duration || DEFAULT_PLACEHOLDER_MS;
58
+ _placeholderTimer = setTimeout(() => {
59
+ const startEl = document.getElementById('hero-inp-start');
60
+ const endEl = document.getElementById('hero-inp-end');
61
+ // Don't fight the user — only advance while both fields are empty, so
62
+ // cycling text never replaces something they've actually typed. Focus
63
+ // alone does NOT block rotation: boot() auto-focuses hero-inp-start on
64
+ // every load, so gating on activeElement here would freeze rotation at
65
+ // the first pair for any visitor who hasn't yet clicked away — there's
66
+ // no real cursor/typed content to interrupt in an empty, placeholder-only
67
+ // field regardless of focus.
68
+ const canAdvance = startEl && endEl && !startEl.value && !endEl.value;
69
+ if (canAdvance) {
70
+ _placeholderIdx = (_placeholderIdx + 1) % PLACEHOLDER_PAIRS.length;
71
+ _applyPlaceholder();
72
+ }
73
+ _scheduleNextPlaceholder();
74
+ }, duration);
75
+ }
76
+
77
+ function startPlaceholderRotation() {
78
+ if (_placeholderTimer) return;
79
+ _placeholderIdx = 0; // Nagpur → Mars first, every time rotation (re)starts
80
+ _applyPlaceholder();
81
+ _scheduleNextPlaceholder();
82
+ }
83
+
84
+ function stopPlaceholderRotation() {
85
+ clearTimeout(_placeholderTimer);
86
+ _placeholderTimer = null;
87
+ }
88
+
89
+ // ── Hero input logic ──────────────────────────────────────────────────────────
90
+ function heroInputChanged() {
91
+ const s = document.getElementById('hero-inp-start').value.trim();
92
+ const e = document.getElementById('hero-inp-end').value.trim();
93
+
94
+ // Labels light up when filled
95
+ document.getElementById('lbl-start').classList.toggle('filled', s.length > 0);
96
+ document.getElementById('lbl-end').classList.toggle('filled', e.length > 0);
97
+
98
+ // Inputs get filled style
99
+ document.getElementById('hero-inp-start').classList.toggle('filled', s.length > 0);
100
+ document.getElementById('hero-inp-end').classList.toggle('filled', e.length > 0);
101
+
102
+ // Arrow lights up when both filled
103
+ document.getElementById('hero-arrow').classList.toggle('lit', s.length > 0 && e.length > 0);
104
+
105
+ // Button and hint appear when both filled
106
+ const ready = s.length > 0 && e.length > 0;
107
+ document.getElementById('hero-btn').classList.toggle('ready', ready);
108
+ document.getElementById('hero-hint').classList.toggle('visible', ready);
109
+ }
110
+ // NOTE: autocomplete (vector search) is wired separately via addEventListener
111
+ // in the NEW block below — heroInputChanged() above is the original,
112
+ // untouched function and does not need to know about autocomplete at all.
113
+
114
+ function heroKeyDown(e) {
115
+ // NEW: if an autocomplete dropdown is open for this field, arrow keys /
116
+ // Enter operate on the dropdown first. This is purely additive — if no
117
+ // dropdown is open, every branch below falls through to the exact
118
+ // original behaviour at the bottom of this function, untouched.
119
+ const fieldName = e.target.id === 'hero-inp-start' ? 'start' : 'end';
120
+ const dropdown = document.getElementById('ac-dropdown-' + fieldName);
121
+ const isOpen = dropdown && dropdown.classList.contains('visible');
122
+
123
+ if (isOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
124
+ e.preventDefault();
125
+ const items = acLastResults[fieldName];
126
+ if (items.length === 0) return;
127
+ const delta = e.key === 'ArrowDown' ? 1 : -1;
128
+ acActiveIndex[fieldName] = (acActiveIndex[fieldName] + delta + items.length) % items.length;
129
+ renderAcDropdown(fieldName);
130
+ return;
131
+ }
132
+ if (isOpen && e.key === 'Enter' && acActiveIndex[fieldName] >= 0) {
133
+ e.preventDefault();
134
+ selectAcSuggestion(fieldName, acLastResults[fieldName][acActiveIndex[fieldName]]);
135
+ return;
136
+ }
137
+ if (isOpen && e.key === 'Escape') {
138
+ closeAcDropdown(fieldName);
139
+ return;
140
+ }
141
+
142
+ // ── Original behaviour (unchanged) ──────────────────────────────────────
143
+ if (e.key === 'Enter') {
144
+ const s = document.getElementById('hero-inp-start').value.trim();
145
+ const en = document.getElementById('hero-inp-end').value.trim();
146
+ if (s && en) startSearch();
147
+ else if (!s) document.getElementById('hero-inp-start').focus();
148
+ else document.getElementById('hero-inp-end').focus();
149
+ }
150
+ }
151
+
152
+ // ── Wikipedia URL extractor ───────────────────────────────────────────────────
153
+ function extractTitleFromWikiUrl(input) {
154
+ const match = input.match(/wikipedia\.org\/wiki\/([^#?]+)/);
155
+ if (match) return decodeURIComponent(match[1]).replace(/_/g, ' ');
156
+ return input;
157
+ }
158
+
159
+ // ════════════════════════════════════════════════════════════════════════════
160
+ // NEW: Vector-search autocomplete (hooks into the existing hero inputs via
161
+ // addEventListener below — does not modify heroInputChanged/heroKeyDown's
162
+ // original bodies, see the dedicated NOTE comments near those functions).
163
+ // ════════════════════════════════════════════════════════════════════════════
164
+
165
+ // Single source of truth for the backend origin. Derived from whatever host
166
+ // the page itself was loaded from (not hardcoded to 'localhost') so this
167
+ // works unmodified whether you open it as localhost, as a LAN IP from
168
+ // another device on the same network (backend already binds 0.0.0.0 — see
169
+ // main.py), or any other dev hostname — as long as the backend listens on
170
+ // the same host, just port 8000. After deploying to separate frontend/
171
+ // backend hosts (e.g. Render), replace this line with the fixed backend URL
172
+ // (e.g. 'https://aurelius-backend.onrender.com') — the WebSocket URL below
173
+ // is derived from it automatically (http→ws, https→wss), so nothing else in
174
+ // this file needs to change.
175
+ // window.location.hostname is '' when the page is opened as a local file
176
+ // (file://index.html, which is how this app is normally launched per
177
+ // CLAUDE.md) rather than served over http(s) — fall back to 'localhost' for
178
+ // that case so AC_BACKEND never becomes the invalid 'http://:8000'.
179
+ const AC_BACKEND = `http://${window.location.hostname || 'localhost'}:8000`;
180
+ const AC_BACKEND_WS = AC_BACKEND.replace(/^http/, 'ws');
181
+ const WIKI_OPENSEARCH = 'https://en.wikipedia.org/w/api.php';
182
+
183
+ /**
184
+ * Debounced fetch directly to Wikipedia's opensearch API. Called on every
185
+ * keystroke in a hero input field, but the actual network request is
186
+ * delayed by AC_DEBOUNCE_MS so rapid typing doesn't spam the API. No
187
+ * backend involved — CORS-enabled, instant, and accurate for any article
188
+ * (not limited to a curated seed list).
189
+ */
190
+ function triggerAutocomplete(fieldName) {
191
+ const inputId = fieldName === 'start' ? 'hero-inp-start' : 'hero-inp-end';
192
+ const query = document.getElementById(inputId).value.trim();
193
+
194
+ if (acDebounceTimers[fieldName]) clearTimeout(acDebounceTimers[fieldName]);
195
+
196
+ if (query.length < 2) {
197
+ closeAcDropdown(fieldName);
198
+ return;
199
+ }
200
+
201
+ acDebounceTimers[fieldName] = setTimeout(async () => {
202
+ try {
203
+ const params = new URLSearchParams({
204
+ action: 'opensearch', search: query, limit: '5',
205
+ namespace: '0', format: 'json', origin: '*',
206
+ });
207
+ const res = await fetch(`${WIKI_OPENSEARCH}?${params}`);
208
+ if (!res.ok) { closeAcDropdown(fieldName); return; }
209
+ const data = await res.json();
210
+ const suggestions = Array.isArray(data) && data.length > 1 ? data[1] : [];
211
+
212
+ // The user may have kept typing while this request was in flight —
213
+ // if the field no longer matches what we searched for, drop the
214
+ // (now-stale) result instead of flashing an outdated dropdown.
215
+ const currentValue = document.getElementById(inputId).value.trim();
216
+ if (currentValue !== query) return;
217
+
218
+ acLastResults[fieldName] = suggestions;
219
+ acActiveIndex[fieldName] = -1;
220
+ renderAcDropdown(fieldName);
221
+ } catch (err) {
222
+ // Network hiccup — fail silently, autocomplete is a nice-to-have
223
+ // and must never block the user from just typing a title and
224
+ // pressing Enter as before.
225
+ closeAcDropdown(fieldName);
226
+ }
227
+ }, AC_DEBOUNCE_MS);
228
+ }
229
+
230
+ /** Render the top-5 suggestions below the given hero field. */
231
+ function renderAcDropdown(fieldName) {
232
+ const dropdown = document.getElementById('ac-dropdown-' + fieldName);
233
+ const items = acLastResults[fieldName];
234
+
235
+ if (!items || items.length === 0) {
236
+ closeAcDropdown(fieldName);
237
+ return;
238
+ }
239
+
240
+ dropdown.innerHTML = items.map((title, i) => `
241
+ <div class="ac-item${i === acActiveIndex[fieldName] ? ' ac-active' : ''}"
242
+ data-idx="${i}" data-title="${title.replace(/"/g, '&quot;')}">
243
+ <svg class="ac-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" width="14" height="14"><path d="M4 2h6l3 3v9H4V2z"/><path d="M10 2v3h3"/><line x1="6" y1="7" x2="11" y2="7"/><line x1="6" y1="10" x2="10" y2="10"/></svg>${title}
244
+ </div>
245
+ `).join('');
246
+ dropdown.classList.add('visible');
247
+ // BUGFIX: previously used inline onclick="selectAcSuggestion('${fieldName}', ${JSON.stringify(title)})"
248
+ // which broke because JSON.stringify's double-quotes collided with the outer HTML
249
+ // attribute's double-quotes, corrupting the attribute and silently failing on click.
250
+ // Event delegation with data-title avoids the quote-collision entirely.
251
+ dropdown.querySelectorAll('.ac-item').forEach(el => {
252
+ el.addEventListener('click', () => selectAcSuggestion(fieldName, el.dataset.title));
253
+ });
254
+ }
255
+
256
+ function closeAcDropdown(fieldName) {
257
+ const dropdown = document.getElementById('ac-dropdown-' + fieldName);
258
+ if (dropdown) {
259
+ dropdown.classList.remove('visible');
260
+ dropdown.innerHTML = '';
261
+ }
262
+ acLastResults[fieldName] = [];
263
+ acActiveIndex[fieldName] = -1;
264
+ }
265
+
266
+ /** Called when the user clicks (or presses Enter on) a suggestion row. */
267
+ function selectAcSuggestion(fieldName, title) {
268
+ const inputId = fieldName === 'start' ? 'hero-inp-start' : 'hero-inp-end';
269
+ document.getElementById(inputId).value = title;
270
+ closeAcDropdown(fieldName);
271
+ // Re-run the existing, untouched hero validation logic (lights up the
272
+ // label/arrow/button exactly as if the user had typed this themselves).
273
+ heroInputChanged();
274
+ }
275
+
276
+ // Close dropdowns when clicking anywhere outside them (standard
277
+ // autocomplete UX) — purely additive, does not interfere with any
278
+ // existing click handlers elsewhere in the app.
279
+ document.addEventListener('click', (e) => {
280
+ if (!e.target.closest('.hero-field')) {
281
+ closeAcDropdown('start');
282
+ closeAcDropdown('end');
283
+ }
284
+ });
285
+
286
+ // ════════════════════════════════════════════════════════════════════════════
287
+ // NEW: Random button — picks two curated topics via /api/random, then
288
+ // reuses the EXISTING startSearch() pipeline untouched (same WebSocket
289
+ // flow, same A* navigator on the backend, same rendering code).
290
+ // ════════════════════════════════════════════════════════════════════════════
291
+ async function useRandomPair() {
292
+ // Per the requirements: only auto-fill if the user hasn't already typed
293
+ // a query themselves — we don't want to clobber a half-typed search.
294
+ const sVal = document.getElementById('hero-inp-start').value.trim();
295
+ const eVal = document.getElementById('hero-inp-end').value.trim();
296
+ if (sVal || eVal) {
297
+ // BUGFIX: setStatus() alone is invisible here — #status-bar lives inside
298
+ // #topbar, which has opacity:0 until the hero is dismissed. Surface the
299
+ // message on the button itself too, since that's always visible.
300
+ setStatus('Clear both fields first to use Random.');
301
+ btn_guard_msg(document.getElementById('hero-random-btn'), 'Clear fields first');
302
+ return;
303
+ }
304
+
305
+ const btn = document.getElementById('hero-random-btn');
306
+ btn.classList.add('loading');
307
+ btn.textContent = 'Picking...';
308
+
309
+ try {
310
+ const res = await fetch(`${AC_BACKEND}/api/random`);
311
+ if (!res.ok) throw new Error('Random pick failed');
312
+ const data = await res.json();
313
+
314
+ document.getElementById('hero-inp-start').value = data.start;
315
+ document.getElementById('hero-inp-end').value = data.end;
316
+ heroInputChanged(); // existing function — lights up labels/arrow/button
317
+
318
+ btn.classList.remove('loading');
319
+ btn.textContent = 'Random';
320
+
321
+ // Small delay so the user can actually see which two topics were
322
+ // picked before the hero screen animates away.
323
+ setTimeout(() => startSearch(), 600);
324
+ } catch (err) {
325
+ btn.classList.remove('loading');
326
+ setStatus('Could not reach backend for Random pick.');
327
+ // BUGFIX: status bar is invisible on hero screen — show error on button too.
328
+ btn_guard_msg(btn, '⚠️ Backend offline');
329
+ }
330
+ }
331
+
332
+ // Small helper for useRandomPair(): briefly shows an error message directly
333
+ // on the button (always visible on hero screen), then restores its label.
334
+ function btn_guard_msg(btn, msg) {
335
+ const original = 'Random';
336
+ btn.textContent = msg;
337
+ setTimeout(() => { btn.textContent = original; }, 1800);
338
+ }
339
+
340
+ // ── Dismiss hero, show app ────────────────────────────────────────────────────
341
+ function dismissHero(start, end) {
342
+ if (heroDismissed) return;
343
+ heroDismissed = true;
344
+ stopPlaceholderRotation();
345
+
346
+ // Copy values to topbar inputs
347
+ document.getElementById('inp-start').value = start;
348
+ document.getElementById('inp-end').value = end;
349
+
350
+ // Animate hero out
351
+ const hero = document.getElementById('hero');
352
+ hero.classList.add('hiding');
353
+ setTimeout(() => hero.classList.add('hidden'), 500);
354
+
355
+ // Animate topbar in
356
+ document.getElementById('topbar').classList.add('visible');
357
+
358
+ // On phones/narrow tablets, default the search-controls topbar and the
359
+ // path/scores/log panel to collapsed the moment the main view opens, so
360
+ // what you see first is the live graph forming the path, not a wall of
361
+ // chrome. Both are reachable again via their handle bars. No-op on
362
+ // desktop/tablet — the CSS that makes .mobile-collapsed do anything only
363
+ // exists under the same max-width:820px breakpoint this check mirrors.
364
+ if (isMobileLayout()) {
365
+ document.getElementById('topbar').classList.add('mobile-collapsed');
366
+ document.getElementById('panel').classList.add('mobile-collapsed');
367
+ _syncMobileHandles();
368
+ }
369
+ }
370
+
371
+ // ── Mobile topbar/panel collapse ──────────────────────────────────────────────
372
+ function isMobileLayout() {
373
+ return window.matchMedia('(max-width: 820px)').matches;
374
+ }
375
+
376
+ function toggleMobileTopbar() {
377
+ document.getElementById('topbar').classList.toggle('mobile-collapsed');
378
+ _syncMobileHandles();
379
+ }
380
+
381
+ function toggleMobilePanel() {
382
+ document.getElementById('panel').classList.toggle('mobile-collapsed');
383
+ _syncMobileHandles();
384
+ }
385
+
386
+ // Keeps each handle's chevron direction (and its own .is-collapsed state,
387
+ // used purely for the CSS rotate transform) matching what it actually
388
+ // controls, since the two can change independently of each other.
389
+ function _syncMobileHandles() {
390
+ const topbarCollapsed = document.getElementById('topbar').classList.contains('mobile-collapsed');
391
+ const panelCollapsed = document.getElementById('panel').classList.contains('mobile-collapsed');
392
+ const th = document.getElementById('mobile-topbar-handle');
393
+ const ph = document.getElementById('mobile-panel-handle');
394
+ if (th) th.classList.toggle('is-collapsed', topbarCollapsed);
395
+ if (ph) ph.classList.toggle('is-collapsed', panelCollapsed);
396
+
397
+ // Collapsing/expanding either bar resizes #canvas-wrap via CSS transition,
398
+ // but that's an internal layout change, not a viewport resize — it never
399
+ // fires the `resize` listener that normally keeps the d3 force simulation
400
+ // centered. Re-measure once the 0.32s transition (styles.css) finishes, or
401
+ // the graph stays centered on the stale pre-toggle canvas size.
402
+ setTimeout(_resyncCanvasSize, 340);
403
+ }
404
+
405
+ function _resyncCanvasSize() {
406
+ if (!simulation) return;
407
+ const cw = document.getElementById('canvas-wrap');
408
+ if (!cw) return;
409
+ width = cw.clientWidth;
410
+ height = cw.clientHeight;
411
+ simulation.force('center', d3.forceCenter(width / 2, height / 2));
412
+ simulation.alpha(0.3).restart();
413
+ }
414
+
415
+ // ── Search ────────────────────────────────────────────────────────────────────
416
+ function startSearch() {
417
+ // Pull from whichever screen is active
418
+ let rawStart, rawEnd;
419
+ if (!heroDismissed) {
420
+ rawStart = document.getElementById('hero-inp-start').value.trim();
421
+ rawEnd = document.getElementById('hero-inp-end').value.trim();
422
+ } else {
423
+ rawStart = document.getElementById('inp-start').value.trim();
424
+ rawEnd = document.getElementById('inp-end').value.trim();
425
+ }
426
+
427
+ if (!rawStart || !rawEnd) {
428
+ setStatus('Please enter both a start and end article.');
429
+ return;
430
+ }
431
+ if (running) return;
432
+
433
+ const start = extractTitleFromWikiUrl(rawStart);
434
+ const end = extractTitleFromWikiUrl(rawEnd);
435
+
436
+ dismissHero(rawStart, rawEnd);
437
+ resetAll(false);
438
+ running = true;
439
+ document.getElementById('btn-search').disabled = true;
440
+ switchTab('log');
441
+ document.getElementById('stats-bar').classList.add('visible');
442
+ document.getElementById('btn-pause-bar').classList.add('visible');
443
+ const stepsLive = document.getElementById('sv-steps-live');
444
+ if (stepsLive) { document.getElementById('sv-steps-count').textContent = '0'; stepsLive.classList.add('visible'); }
445
+ paused = false;
446
+ foundPath = [];
447
+
448
+ addLog(`Starting: "${start}" → "${end}"`, 'highlight');
449
+ setStatus('Connecting...');
450
+
451
+ const gen = ++_searchGen;
452
+ ws = new WebSocket(`${AC_BACKEND_WS}/ws`);
453
+
454
+ ws.onopen = () => {
455
+ if (gen !== _searchGen) return;
456
+ ws.send(JSON.stringify({ start, end }));
457
+ setStatus('Connected. Running A*...');
458
+ };
459
+
460
+ ws.onmessage = e => {
461
+ if (gen !== _searchGen) return;
462
+ try { handleMessage(JSON.parse(e.data)); }
463
+ catch (err) { console.error('Parse error:', err); }
464
+ };
465
+
466
+ ws.onerror = () => {
467
+ if (gen !== _searchGen) return;
468
+ // Only a connection error mid-search is a real problem. When the search
469
+ // finishes, the backend returns from the WS handler and closes the
470
+ // socket — mobile browsers (Safari/Chrome on iOS especially) surface
471
+ // that normal teardown as an `error` event, which would otherwise fire
472
+ // this scary "is the backend running?" message even though the path was
473
+ // found seconds earlier. `running` is false once found/not_found/error
474
+ // has been handled, so bail out — there's nothing wrong to report.
475
+ if (!running) return;
476
+ setStatus('⚠️ Cannot connect to backend. Is main.py running?');
477
+ addLog('WebSocket error — is the backend running? (python main.py)', 'error');
478
+ document.getElementById('btn-search').disabled = false;
479
+ running = false;
480
+ };
481
+
482
+ ws.onclose = () => {
483
+ if (gen !== _searchGen) return;
484
+ if (running) {
485
+ setStatus('Connection closed.');
486
+ running = false;
487
+ document.getElementById('btn-search').disabled = false;
488
+ }
489
+ };
490
+ }
491
+
492
+ function resetAll(showHero = true) {
493
+ if (ws) { ws.close(); ws = null; }
494
+ nodes = {}; edges = []; centreNode = null;
495
+ pathNodes = new Set(); scoreCards = {};
496
+ running = false;
497
+ // BUGFIX: "New Search" lives inside #success-modal itself and calls
498
+ // resetAll(true) directly — without this, the stale "Path Discovered"
499
+ // modal stays visible on top of the freshly-reset hero screen,
500
+ // permanently blocking it until the next search happens to find a path.
501
+ document.getElementById('success-modal').classList.remove('visible');
502
+ document.getElementById('btn-search').disabled = false;
503
+ document.getElementById('path-banner').classList.remove('visible');
504
+ document.getElementById('path-display').innerHTML = '<p style="font-size:13px;color:var(--text2)">Path will appear here as A* explores.</p>';
505
+ document.getElementById('scores-list').innerHTML = '';
506
+ document.getElementById('log').innerHTML = '';
507
+ setStatus('Enter two Wikipedia topics and hit Find Path.');
508
+ document.getElementById('stats-bar').classList.remove('visible');
509
+ _hidePauseBtn();
510
+ document.getElementById('btn-export').classList.remove('visible');
511
+ paused = false;
512
+ foundPath = [];
513
+
514
+ if (showHero) {
515
+ heroDismissed = false;
516
+ document.getElementById('topbar').classList.remove('visible');
517
+ document.getElementById('topbar').classList.remove('mobile-collapsed');
518
+ document.getElementById('panel').classList.remove('mobile-collapsed');
519
+ const hero = document.getElementById('hero');
520
+ hero.classList.remove('hiding', 'hidden');
521
+ document.getElementById('hero-inp-start').value = '';
522
+ document.getElementById('hero-inp-end').value = '';
523
+ heroInputChanged();
524
+ resetHeroDisplay();
525
+ }
526
+
527
+ initSVG();
528
+ }
529
+
530
+ // ── Init SVG ──────────────────────────────────────────────────────────────────
531
+ function initSVG() {
532
+ svgEl = d3.select('#graph');
533
+ width = document.getElementById('canvas-wrap').clientWidth;
534
+ height = document.getElementById('canvas-wrap').clientHeight;
535
+
536
+ svgEl.selectAll('*').remove();
537
+
538
+ // Define glow filter
539
+ const defs = svgEl.append('defs');
540
+ const glow = defs.append('filter')
541
+ .attr('id', 'glow')
542
+ .attr('x', '-30%')
543
+ .attr('y', '-30%')
544
+ .attr('width', '160%')
545
+ .attr('height', '160%');
546
+ glow.append('feGaussianBlur')
547
+ .attr('stdDeviation', '6')
548
+ .attr('result', 'blur');
549
+ glow.append('feMerge')
550
+ .append('feMergeNode').attr('in', 'blur');
551
+ glow.select('feMerge')
552
+ .append('feMergeNode').attr('in', 'SourceGraphic');
553
+
554
+ const zoom = d3.zoom()
555
+ .scaleExtent([0.2, 4])
556
+ .on('zoom', e => gEl.attr('transform', e.transform));
557
+ svgEl.call(zoom);
558
+
559
+ gEl = svgEl.append('g').attr('id', 'main-g');
560
+ gEl.append('g').attr('id', 'edge-layer');
561
+ gEl.append('g').attr('id', 'node-layer');
562
+ gEl.append('g').attr('id', 'label-layer');
563
+
564
+ simulation = d3.forceSimulation()
565
+ .force('link', d3.forceLink().id(d => d.id).distance(120).strength(0.4))
566
+ // distanceMax caps how far the mutual-repulsion force reaches between any
567
+ // two nodes. Without it, a long-running search (up to 60 expansions, 25
568
+ // new nodes per step) keeps accumulating cumulative repulsion across the
569
+ // WHOLE graph — every node keeps pushing every other node apart forever,
570
+ // so the cluster's total footprint grows without bound as more nodes
571
+ // appear, even though the camera's own zoom level never changes. That
572
+ // unbounded sprawl is what reads as "the page automatically zooms out" —
573
+ // capping the repulsion's reach keeps the graph's footprint roughly
574
+ // stable once nodes are a few hundred px apart, instead of ballooning.
575
+ .force('charge', d3.forceManyBody().strength(-300).distanceMax(420))
576
+ .force('center', d3.forceCenter(width / 2, height / 2))
577
+ .force('collide', d3.forceCollide(44))
578
+ .alphaDecay(0.02)
579
+ .on('tick', ticked);
580
+ }
581
+
582
+ // ── D3 tick ───────────────────────────────────────────────────────────────────
583
+ function ticked() {
584
+ d3.selectAll('.edge-line')
585
+ .attr('x1', d => (nodes[d.from] || {}).x || 0)
586
+ .attr('y1', d => (nodes[d.from] || {}).y || 0)
587
+ .attr('x2', d => (nodes[d.to] || {}).x || 0)
588
+ .attr('y2', d => (nodes[d.to] || {}).y || 0);
589
+
590
+ d3.selectAll('.node-circle')
591
+ .attr('cx', d => d.x)
592
+ .attr('cy', d => d.y);
593
+
594
+ d3.selectAll('.node-label-el')
595
+ .attr('x', d => d.x)
596
+ .attr('y', d => d.y + nodeRadius(d) + 15);
597
+ }
598
+
599
+ function nodeRadius(d) {
600
+ if (d.state === 'centre') return 22;
601
+ if (d.state === 'target') return 18;
602
+ if (pathNodes.has(d.id)) return 15;
603
+ return 12;
604
+ }
605
+
606
+ function nodeColor(d) {
607
+ const s = getComputedStyle(document.documentElement);
608
+ if (d.state === 'centre') return s.getPropertyValue('--node-centre').trim();
609
+ if (d.state === 'target') return s.getPropertyValue('--node-target').trim();
610
+ if (d.state === 'closed') return '#374151';
611
+ if (d.state === 'gated') return '#4b2020';
612
+ if (pathNodes.has(d.id)) return foundPath.length ? '#34d399' : s.getPropertyValue('--node-path').trim();
613
+ return s.getPropertyValue('--node-open').trim();
614
+ }
615
+
616
+ function edgeColor(d) {
617
+ if (d.isPath) return foundPath.length ? 'rgba(52,211,153,0.9)' : 'rgba(249,115,22,0.85)';
618
+ const fromNode = nodes[d.from];
619
+ if (fromNode && fromNode.state === 'closed') return 'rgba(124,106,247,0.18)';
620
+ return 'rgba(124,106,247,0.38)';
621
+ }
622
+ function edgeWidth(d) { return d.isPath ? 3 : 1.2; }
623
+
624
+ // ── Render ────────────────────────────────────────────────────────────────────
625
+ function render() {
626
+ const nodeArr = Object.values(nodes);
627
+
628
+ const edgeSel = d3.select('#edge-layer')
629
+ .selectAll('.edge-line')
630
+ .data(edges, d => d.from + '→' + d.to);
631
+
632
+ edgeSel.enter().append('line')
633
+ .attr('class', 'edge-line')
634
+ .attr('stroke-opacity', 0)
635
+ .attr('stroke-linecap', 'round')
636
+ .transition().duration(400)
637
+ .attr('stroke-opacity', 1);
638
+
639
+ edgeSel.exit().remove();
640
+
641
+ d3.selectAll('.edge-line')
642
+ .attr('stroke', edgeColor)
643
+ .attr('stroke-width', edgeWidth)
644
+ .attr('class', d => d.isPath && foundPath.length ? 'edge-line edge-path-found' : 'edge-line');
645
+
646
+ const circSel = d3.select('#node-layer')
647
+ .selectAll('.node-circle')
648
+ .data(nodeArr, d => d.id);
649
+
650
+ circSel.enter().append('circle')
651
+ .attr('class', 'node-circle')
652
+ .attr('r', 0)
653
+ .attr('fill', nodeColor)
654
+ .attr('stroke', '#0a0a0f')
655
+ .attr('stroke-width', 2.5)
656
+ .style('cursor', 'pointer')
657
+ .on('mouseover', onNodeHover)
658
+ .on('mouseout', onNodeOut)
659
+ .on('click', onNodeClick)
660
+ .call(d3.drag()
661
+ .on('start', dragStart)
662
+ .on('drag', dragged)
663
+ .on('end', dragEnd))
664
+ .transition().duration(350)
665
+ .attr('r', nodeRadius);
666
+
667
+ d3.selectAll('.node-circle')
668
+ .attr('class', 'node-circle')
669
+ .attr('fill', nodeColor)
670
+ .style('filter', d => {
671
+ if (d.state === 'centre' || d.state === 'target' || pathNodes.has(d.id)) {
672
+ return 'url(#glow)';
673
+ }
674
+ return 'none';
675
+ })
676
+ .transition().duration(200)
677
+ .attr('r', nodeRadius);
678
+
679
+ circSel.exit().transition().duration(200).attr('r', 0).remove();
680
+
681
+ const lblSel = d3.select('#label-layer')
682
+ .selectAll('.node-label-el')
683
+ .data(nodeArr, d => d.id);
684
+
685
+ lblSel.enter().append('text')
686
+ .attr('class', d => 'node-label-el ' + (d.state === 'centre' ? 'centre-label' : 'node-label'))
687
+ .attr('opacity', 0)
688
+ .transition().duration(400)
689
+ .attr('opacity', d => {
690
+ if (d.state === 'centre' || d.state === 'target') return 1;
691
+ if (pathNodes.has(d.id)) return 0.9;
692
+ return 0.45;
693
+ });
694
+
695
+ d3.selectAll('.node-label-el')
696
+ .text(d => truncate(d.id, d.state === 'centre' ? 24 : 18))
697
+ .attr('class', d => 'node-label-el ' + (d.state === 'centre' ? 'centre-label' : 'node-label'))
698
+ .attr('fill', d =>
699
+ d.state === 'centre' ? 'var(--text)' :
700
+ d.state === 'target' ? 'var(--green)' : 'var(--text2)')
701
+ .transition().duration(300)
702
+ .attr('opacity', d => {
703
+ if (d.state === 'centre' || d.state === 'target') return 1;
704
+ if (pathNodes.has(d.id)) return 0.9;
705
+ return 0.42;
706
+ });
707
+
708
+ lblSel.exit().remove();
709
+
710
+ simulation.nodes(nodeArr);
711
+ simulation.force('link').links(edges.map(e => ({
712
+ source: e.from, target: e.to, isPath: e.isPath
713
+ })));
714
+ simulation.alpha(0.3).restart();
715
+ }
716
+
717
+ // ── Pan to node ───────────────────────────────────────────────────────────────
718
+ function panToNode(nodeId) {
719
+ const n = nodes[nodeId];
720
+ if (!n || !n.x) return;
721
+ const cw = document.getElementById('canvas-wrap').clientWidth;
722
+ const ch = document.getElementById('canvas-wrap').clientHeight;
723
+ const t = d3.zoomTransform(svgEl.node());
724
+ const tx = cw / 2 - t.k * n.x;
725
+ const ty = ch / 2 - t.k * n.y;
726
+ svgEl.transition().duration(600).ease(d3.easeCubicInOut)
727
+ .call(d3.zoom().transform, d3.zoomIdentity.translate(tx, ty).scale(t.k));
728
+ }
729
+
730
+ // ── Drag ──────────────────────────────────────────────────────────────────────
731
+ function dragStart(e, d) { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }
732
+ function dragged(e, d) { d.fx = e.x; d.fy = e.y; }
733
+ function dragEnd(e, d) { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }
734
+
735
+ // ── Tooltip ───────────────────────────────────────────────────────────────────
736
+ function onNodeHover(e, d) {
737
+ const tip = document.getElementById('tooltip');
738
+ document.getElementById('tt-title').textContent = d.id;
739
+ const scores = [];
740
+ if (d.g != null) scores.push(`g(n) = ${d.g} hops from start`);
741
+ if (d.h != null) scores.push(`h(n) = ${d.h} heuristic`);
742
+ if (d.f != null) scores.push(`f(n) = ${d.f} total`);
743
+ document.getElementById('tt-scores').innerHTML = scores.join('<br>');
744
+ tip.style.left = (e.pageX + 14) + 'px';
745
+ tip.style.top = (e.pageY - 32) + 'px';
746
+ tip.classList.add('visible');
747
+ }
748
+ function onNodeOut() { document.getElementById('tooltip').classList.remove('visible'); }
749
+ function onNodeClick(e, d) { panToNode(d.id); }
750
+
751
+ // ── Helpers ───────────────────────────────────────────────────────────────────
752
+ function truncate(s, n) { return s.length > n ? s.slice(0, n - 1) + '…' : s; }
753
+ function setStatus(msg) { document.getElementById('status-bar').textContent = msg; }
754
+
755
+ function addLog(msg, type = '') {
756
+ const log = document.getElementById('log');
757
+ const entry = document.createElement('div');
758
+ entry.className = 'log-entry ' + type;
759
+ const time = new Date().toLocaleTimeString('en', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
760
+ entry.textContent = `[${time}] ${msg}`;
761
+ log.insertBefore(entry, log.firstChild);
762
+ }
763
+
764
+ function switchTab(name) {
765
+ document.querySelectorAll('.tab').forEach((t, i) => {
766
+ const names = ['path', 'scores', 'log', 'legend'];
767
+ t.classList.toggle('active', names[i] === name);
768
+ });
769
+ document.querySelectorAll('.tab-content').forEach(c => {
770
+ c.classList.toggle('active', c.id === 'tab-' + name);
771
+ });
772
+ }
773
+
774
+ function updatePathPanel(pathArr) {
775
+ const div = document.getElementById('path-display');
776
+ if (!pathArr || pathArr.length === 0) return;
777
+ div.innerHTML = '';
778
+ pathArr.forEach((p, i) => {
779
+ const row = document.createElement('div');
780
+ row.className = 'path-step';
781
+ const dot = document.createElement('div');
782
+ dot.className = 'dot' + (i === 0 ? ' start' : i === pathArr.length - 1 ? ' end' : '');
783
+
784
+ const btn = document.createElement('a');
785
+ btn.className = 'hop-btn-link' + (i === 0 ? ' start-hop' : i === pathArr.length - 1 ? ' end-hop' : '');
786
+ btn.href = 'https://en.wikipedia.org/wiki/' + encodeURIComponent(p.replace(/ /g, '_'));
787
+ btn.target = '_blank';
788
+ btn.textContent = p;
789
+
790
+ row.appendChild(dot);
791
+ row.appendChild(btn);
792
+ div.appendChild(row);
793
+ if (i < pathArr.length - 1) {
794
+ const arr = document.createElement('div');
795
+ arr.className = 'path-step';
796
+ arr.style.margin = '2px 0 2px 4px';
797
+ arr.innerHTML = '<span class="arrow">↓</span>';
798
+ div.appendChild(arr);
799
+ }
800
+ });
801
+ }
802
+
803
+ function updateScores(nodeData) {
804
+ scoreCards[nodeData.id] = nodeData;
805
+ const list = document.getElementById('scores-list');
806
+ list.innerHTML = '';
807
+ const sorted = Object.values(scoreCards)
808
+ .filter(n => n.f != null)
809
+ .sort((a, b) => a.f - b.f)
810
+ .slice(0, 30);
811
+ sorted.forEach(n => {
812
+ const card = document.createElement('div');
813
+ card.className = 'score-card';
814
+ card.innerHTML = `
815
+ <div class="title">${n.id}</div>
816
+ <div class="score-row"><span>f(n)</span><span>${n.f}</span></div>
817
+ <div class="score-row"><span>g(n)</span><span>${n.g ?? '—'}</span></div>
818
+ <div class="score-row"><span>h(n)</span><span>${n.h ?? '—'}</span></div>
819
+ `;
820
+ list.appendChild(card);
821
+ });
822
+ }
823
+
824
+ function showPathBanner(pathArr) {
825
+ const banner = document.getElementById('path-banner');
826
+ banner.innerHTML = '';
827
+ banner.classList.add('visible');
828
+ const lbl = document.createElement('span');
829
+ lbl.style.cssText = 'font-size:13px;color:var(--text2);margin-right:12px;white-space:nowrap;font-weight:600';
830
+ lbl.textContent = `✓ Path (${pathArr.length - 1} hops):`;
831
+ banner.appendChild(lbl);
832
+ pathArr.forEach((p, i) => {
833
+ const btn = document.createElement('a');
834
+ btn.className = 'hop-btn-link' + (i === 0 ? ' start-hop' : i === pathArr.length - 1 ? ' end-hop' : '');
835
+ btn.style.flex = 'none';
836
+ btn.href = 'https://en.wikipedia.org/wiki/' + encodeURIComponent(p.replace(/ /g, '_'));
837
+ btn.target = '_blank';
838
+ btn.textContent = p;
839
+ banner.appendChild(btn);
840
+ if (i < pathArr.length - 1) {
841
+ const arr = document.createElement('span');
842
+ arr.className = 'banner-arrow';
843
+ arr.textContent = '→';
844
+ banner.appendChild(arr);
845
+ }
846
+ });
847
+ }
848
+
849
+ // ── WebSocket message handler ─────────────────────────────────────────────────
850
+ function handleMessage(msg) {
851
+ switch (msg.event) {
852
+
853
+ case 'status':
854
+ setStatus(msg.message);
855
+ addLog(msg.message);
856
+ break;
857
+
858
+ case 'resolved':
859
+ addLog(`Resolved: "${msg.start}" → "${msg.end}"`, 'highlight');
860
+ setStatus(`Navigating: ${msg.start} → ${msg.end}`);
861
+ break;
862
+
863
+ case 'node_add': {
864
+ nodes[msg.id] = {
865
+ id: msg.id, g: msg.g, h: msg.h, f: msg.f,
866
+ state: msg.state,
867
+ x: width / 2 + (Math.random() - .5) * 120,
868
+ y: height / 2 + (Math.random() - .5) * 120,
869
+ vx: 0, vy: 0,
870
+ };
871
+ updateScores(msg);
872
+ render();
873
+ break;
874
+ }
875
+
876
+ case 'expand_node': {
877
+ if (centreNode && centreNode !== msg.id) {
878
+ if (nodes[centreNode]) nodes[centreNode].state = 'open';
879
+ }
880
+ centreNode = msg.id;
881
+ if (nodes[msg.id]) {
882
+ nodes[msg.id].state = 'centre';
883
+ nodes[msg.id].g = msg.g;
884
+ nodes[msg.id].h = msg.h;
885
+ nodes[msg.id].f = msg.f;
886
+ }
887
+
888
+ pathNodes = new Set(msg.path_so_far || []);
889
+ edges.forEach(e => {
890
+ const pArr = msg.path_so_far || [];
891
+ e.isPath = false;
892
+ for (let i = 0; i < pArr.length - 1; i++) {
893
+ if ((e.from === pArr[i] && e.to === pArr[i + 1]) ||
894
+ (e.to === pArr[i] && e.from === pArr[i + 1])) {
895
+ e.isPath = true;
896
+ }
897
+ }
898
+ });
899
+
900
+ updatePathPanel(msg.path_so_far);
901
+ updateScores(msg);
902
+ addLog(`Expanding: ${msg.id} f=${msg.f}`, 'highlight');
903
+ if (msg.stats) _updateStats(msg.stats);
904
+ render();
905
+ setTimeout(() => panToNode(msg.id), 200);
906
+ break;
907
+ }
908
+
909
+ case 'node_state': {
910
+ if (nodes[msg.id]) nodes[msg.id].state = msg.state;
911
+ render();
912
+ break;
913
+ }
914
+
915
+ case 'neighbours': {
916
+ msg.nodes.forEach(n => {
917
+ if (!nodes[n.id]) {
918
+ const cn = nodes[msg.centre] || { x: width / 2, y: height / 2 };
919
+ const angle = Math.random() * 2 * Math.PI;
920
+ const dist = 90 + Math.random() * 70;
921
+ nodes[n.id] = {
922
+ id: n.id, g: n.g, h: n.h, f: n.f,
923
+ state: n.state,
924
+ x: cn.x + Math.cos(angle) * dist,
925
+ y: cn.y + Math.sin(angle) * dist,
926
+ vx: 0, vy: 0,
927
+ };
928
+ } else {
929
+ nodes[n.id].g = n.g;
930
+ nodes[n.id].h = n.h;
931
+ nodes[n.id].f = n.f;
932
+ if (nodes[n.id].state !== 'target' && nodes[n.id].state !== 'centre') {
933
+ nodes[n.id].state = n.state;
934
+ }
935
+ }
936
+ updateScores(n);
937
+ });
938
+
939
+ msg.edges.forEach(e => {
940
+ const exists = edges.find(ex => ex.from === e.from && ex.to === e.to);
941
+ if (!exists) edges.push({ from: e.from, to: e.to, isPath: false });
942
+ });
943
+
944
+ render();
945
+ break;
946
+ }
947
+
948
+ case 'found': {
949
+ const path = msg.path;
950
+ pathNodes = new Set(path);
951
+
952
+ // Synthesize missing nodes: the meeting check (d1/d2) skips the
953
+ // neighbours event, so bridge nodes may not exist in the frontend.
954
+ path.forEach((id, i) => {
955
+ if (!nodes[id]) {
956
+ const prev = i > 0 ? nodes[path[i - 1]] : null;
957
+ nodes[id] = {
958
+ id, g: i, h: 0, f: i,
959
+ state: 'path',
960
+ x: (prev ? prev.x : width / 2) + (Math.random() - .5) * 100,
961
+ y: (prev ? prev.y : height / 2) + (Math.random() - .5) * 100,
962
+ vx: 0, vy: 0,
963
+ };
964
+ }
965
+ });
966
+
967
+ path.forEach(id => { if (nodes[id]) nodes[id].state = 'path'; });
968
+ if (nodes[path[0]]) nodes[path[0]].state = 'start';
969
+ if (nodes[path[path.length - 1]]) nodes[path[path.length - 1]].state = 'target';
970
+ edges.forEach(e => {
971
+ e.isPath = false;
972
+ for (let i = 0; i < path.length - 1; i++) {
973
+ if ((e.from === path[i] && e.to === path[i + 1]) ||
974
+ (e.to === path[i] && e.from === path[i + 1])) {
975
+ e.isPath = true;
976
+ }
977
+ }
978
+ });
979
+ for (let i = 0; i < path.length - 1; i++) {
980
+ const a = path[i], b = path[i + 1];
981
+ const exists = edges.find(e =>
982
+ (e.from === a && e.to === b) || (e.from === b && e.to === a));
983
+ if (exists) {
984
+ exists.isPath = true;
985
+ } else {
986
+ edges.push({ from: a, to: b, isPath: true });
987
+ }
988
+ }
989
+ updatePathPanel(path);
990
+ showPathBanner(path);
991
+ addLog(`✓ Found! ${path.join(' → ')} (${msg.total_hops} hops, ${msg.steps} steps)`, 'success');
992
+ setStatus(`Done! Path found in ${msg.total_hops} hops.`);
993
+ render();
994
+ document.getElementById('btn-search').disabled = false;
995
+ running = false;
996
+ foundPath = path;
997
+ _hidePauseBtn();
998
+ document.getElementById('btn-export').classList.add('visible');
999
+ if (msg.stats) _updateStats(msg.stats);
1000
+ animatePath(path, msg.steps, msg.total_hops, msg.display_texts);
1001
+ break;
1002
+ }
1003
+
1004
+ case 'not_found': {
1005
+ addLog(`✗ ${msg.message}`, 'error');
1006
+ setStatus(msg.message);
1007
+ document.getElementById('btn-search').disabled = false;
1008
+ running = false;
1009
+ _hidePauseBtn();
1010
+ if (msg.stats) _updateStats(msg.stats);
1011
+ break;
1012
+ }
1013
+
1014
+ case 'stats': {
1015
+ _updateStats(msg);
1016
+ break;
1017
+ }
1018
+
1019
+
1020
+ case 'error': {
1021
+ addLog(`Error: ${msg.message}`, 'error');
1022
+ setStatus(`Error: ${msg.message}`);
1023
+ document.getElementById('btn-search').disabled = false;
1024
+ running = false;
1025
+ _hidePauseBtn();
1026
+ break;
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+
1032
+ // ── Stats update ─────────────────────────────────────────────────────────────
1033
+ function _updateStats(s) {
1034
+ if (!s) return;
1035
+ _lastElapsedS = s.elapsed_s ?? 0;
1036
+ const cnt = document.getElementById('sv-steps-count');
1037
+ if (cnt) cnt.textContent = (s.nodes_visited ?? 0).toLocaleString();
1038
+ }
1039
+
1040
+ // ── Pause / Resume ────────────────────────────────────────────────────────────
1041
+ function togglePause() {
1042
+ paused = !paused;
1043
+ const btn = document.getElementById('btn-pause-bar');
1044
+ if (paused) {
1045
+ btn.textContent = '▶ Resume';
1046
+ btn.classList.add('paused');
1047
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify({ control: 'pause' }));
1048
+ } else {
1049
+ btn.textContent = '⏸ Pause';
1050
+ btn.classList.remove('paused');
1051
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify({ control: 'resume' }));
1052
+ }
1053
+ }
1054
+
1055
+ function _hidePauseBtn() {
1056
+ paused = false;
1057
+ const btn = document.getElementById('btn-pause-bar');
1058
+ btn.classList.remove('visible', 'paused');
1059
+ btn.textContent = '⏸ Pause';
1060
+ const sl = document.getElementById('sv-steps-live');
1061
+ if (sl) sl.classList.remove('visible');
1062
+ }
1063
+
1064
+ // ── Export path ───────────────────────────────────────────────────────────────
1065
+ function exportPath() {
1066
+ if (!foundPath.length) return;
1067
+ const lines = [
1068
+ 'Aurelius — Path Export',
1069
+ '='.repeat(50),
1070
+ '',
1071
+ `Path (${foundPath.length - 1} hops):`,
1072
+ '',
1073
+ ...foundPath.map((p, i) => {
1074
+ const url = 'https://en.wikipedia.org/wiki/' + encodeURIComponent(p.replace(/ /g, '_'));
1075
+ return `${i + 1}. ${p}\n ${url}`;
1076
+ }),
1077
+ '',
1078
+ `Exported: ${new Date().toLocaleString()}`,
1079
+ ];
1080
+ const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
1081
+ const a = document.createElement('a');
1082
+ a.href = URL.createObjectURL(blob);
1083
+ a.download = 'aurelius-path.txt';
1084
+ a.click();
1085
+ }
1086
+
1087
+ // ── Keyboard shortcuts ────────────────────────────────────────────────────────
1088
+ document.addEventListener('keydown', e => {
1089
+ if (e.target.tagName === 'INPUT') return;
1090
+ if (e.code === 'Space') { e.preventDefault(); if (running) togglePause(); }
1091
+ if (e.code === 'KeyR') { resetAll(); }
1092
+ if (e.code === 'KeyE') { exportPath(); }
1093
+ if (e.key === '?') { openAboutModal(); }
1094
+ });
1095
+
1096
+ function swapInputs() {
1097
+ const s = document.getElementById('inp-start');
1098
+ const en = document.getElementById('inp-end');
1099
+ if (!s || !en) return;
1100
+ [s.value, en.value] = [en.value, s.value];
1101
+ }
1102
+
1103
+ function logoClick() {
1104
+ resetAll(true);
1105
+ }
1106
+
1107
+ // ── Boot ──────────────────────────────────────────────────────────────────────
1108
+ window.addEventListener('load', () => {
1109
+ initSVG();
1110
+ // Focus first hero input
1111
+ document.getElementById('hero-inp-start').focus();
1112
+
1113
+ window.addEventListener('resize', () => {
1114
+ width = document.getElementById('canvas-wrap').clientWidth;
1115
+ height = document.getElementById('canvas-wrap').clientHeight;
1116
+ if (simulation) simulation.force('center', d3.forceCenter(width / 2, height / 2));
1117
+ });
1118
+
1119
+ // NEW: wire vector-search autocomplete onto the two hero inputs. This is
1120
+ // a separate addEventListener (not the existing oninput="heroInputChanged()"
1121
+ // attribute already on these elements) so both the original validation
1122
+ // logic and the new autocomplete logic run independently, side by side,
1123
+ // every time the user types — neither one touches or depends on the other.
1124
+ document.getElementById('hero-inp-start')
1125
+ .addEventListener('input', () => triggerAutocomplete('start'));
1126
+ document.getElementById('hero-inp-end')
1127
+ .addEventListener('input', () => triggerAutocomplete('end'));
1128
+ });
1129
+
1130
+ function animatePath(path, steps, hops, displayTexts) {
1131
+ const delay = 160;
1132
+ path.forEach((nodeId, i) => {
1133
+ setTimeout(() => {
1134
+ d3.selectAll('.node-circle')
1135
+ .filter(d => d.id === nodeId)
1136
+ .transition().duration(120)
1137
+ .attr('r', d => nodeRadius(d) * 1.7)
1138
+ .attr('fill', '#34d399')
1139
+ .transition().duration(260)
1140
+ .attr('r', d => nodeRadius(d))
1141
+ .attr('fill', nodeColor);
1142
+ }, i * delay);
1143
+ });
1144
+ setTimeout(() => openSuccessModal(path, steps, hops, displayTexts), path.length * delay + 480);
1145
+ }
1146
+
1147
+ function openSuccessModal(path, steps, hops, displayTexts) {
1148
+ document.getElementById('modal-hops').textContent = hops;
1149
+ document.getElementById('modal-steps').textContent = steps;
1150
+ const elapsed = _lastElapsedS;
1151
+ document.getElementById('modal-elapsed').textContent = elapsed >= 60
1152
+ ? Math.floor(elapsed / 60) + 'm ' + (elapsed % 60) + 's'
1153
+ : elapsed + 's';
1154
+
1155
+ const container = document.getElementById('modal-path-container');
1156
+ container.innerHTML = '';
1157
+ path.forEach((p, i) => {
1158
+ const btn = document.createElement('a');
1159
+ btn.className = 'hop-btn' + (i === 0 ? ' start-hop' : i === path.length - 1 ? ' end-hop' : '');
1160
+ btn.href = 'https://en.wikipedia.org/wiki/' + encodeURIComponent(p.replace(/ /g, '_'));
1161
+ btn.target = '_blank';
1162
+ btn.innerHTML = `
1163
+ <span>${p}</span>
1164
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:4px;opacity:0.7">
1165
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
1166
+ <polyline points="15 3 21 3 21 9"></polyline>
1167
+ <line x1="10" y1="14" x2="21" y2="3"></line>
1168
+ </svg>`;
1169
+ container.appendChild(btn);
1170
+ if (i < path.length - 1) {
1171
+ const arr = document.createElement('span');
1172
+ arr.className = 'modal-arrow';
1173
+ arr.textContent = '→';
1174
+ const piped = displayTexts && displayTexts[i];
1175
+ if (piped) {
1176
+ arr.classList.add('piped-edge');
1177
+ arr.title = `Shown as "${piped}" in the article text`;
1178
+ const note = document.createElement('span');
1179
+ note.className = 'modal-piped-note';
1180
+ note.textContent = `"${piped}"`;
1181
+ arr.appendChild(note);
1182
+ }
1183
+ container.appendChild(arr);
1184
+ }
1185
+ });
1186
+
1187
+ document.getElementById('modal-copy-btn').textContent = 'Copy Path';
1188
+ document.getElementById('success-modal').classList.add('visible');
1189
+ }
1190
+
1191
+ function closeSuccessModal(e) {
1192
+ if (!e || e.target === document.getElementById('success-modal') || e.target.tagName === 'BUTTON') {
1193
+ document.getElementById('success-modal').classList.remove('visible');
1194
+ }
1195
+ }
1196
+
1197
+ function copyPath() {
1198
+ if (!foundPath.length) return;
1199
+ const text = foundPath.join(' → ');
1200
+ const btn = document.getElementById('modal-copy-btn');
1201
+ navigator.clipboard.writeText(text).then(() => {
1202
+ if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy Path'; }, 1600); }
1203
+ }).catch(() => {
1204
+ const ta = document.createElement('textarea');
1205
+ ta.value = text;
1206
+ ta.style.position = 'fixed'; ta.style.opacity = '0';
1207
+ document.body.appendChild(ta);
1208
+ ta.select();
1209
+ document.execCommand('copy');
1210
+ document.body.removeChild(ta);
1211
+ if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy Path'; }, 1600); }
1212
+ });
1213
+ }
1214
+
1215
+ function openAboutModal() {
1216
+ document.getElementById('about-modal').classList.add('visible');
1217
+ }
1218
+
1219
+ function closeAboutModal(e) {
1220
+ if (!e || e.target === document.getElementById('about-modal') || e.target.tagName === 'BUTTON') {
1221
+ document.getElementById('about-modal').classList.remove('visible');
1222
+ }
1223
+ }
1224
+
1225
+ // ════════════════════════════════════════════════════════════════════════════
1226
+ // Loading screen + landing entrance sequence.
1227
+ // Polls /api/health until the backend's embedding model is ready (the
1228
+ // server doesn't accept connections until startup finishes loading it, so
1229
+ // a single successful response already means "ready" — no extra check
1230
+ // needed). Once ready: fade out the loading screen and trigger the title's
1231
+ // 3-stage entrance animation — landing position -> grow to viewport center
1232
+ // -> hold -> return to landing position. The rest of the hero
1233
+ // (.hero-reveal elements) and the hero background (#hero-bg-mask) fade in
1234
+ // together exactly when that animation finishes, driven by the
1235
+ // animationend event rather than a magic setTimeout that would drift out
1236
+ // of sync if the CSS timing is ever tuned.
1237
+ // ════════════════════════════════════════════════════════════════════════════
1238
+ const HEALTH_POLL_MS = 350;
1239
+
1240
+ async function initLoadingSequence() {
1241
+ const loadingScreen = document.getElementById('loading-screen');
1242
+
1243
+ while (true) {
1244
+ try {
1245
+ const res = await fetch(`${AC_BACKEND}/api/health`);
1246
+ if (res.ok) break;
1247
+ } catch (err) {
1248
+ // Backend not reachable yet — keep polling silently.
1249
+ }
1250
+ await new Promise(r => setTimeout(r, HEALTH_POLL_MS));
1251
+ }
1252
+
1253
+ loadingScreen.classList.add('fade-out');
1254
+ setTimeout(() => loadingScreen.classList.add('hidden'), 550);
1255
+
1256
+ playHeroEntrance();
1257
+ }
1258
+
1259
+ // Drives the hero title's 3-stage entrance animation. Called ONLY on first
1260
+ // load (initLoadingSequence(), below) — this is a one-time first-impression
1261
+ // flourish, not something to replay on every return to the hero screen.
1262
+ //
1263
+ // BUGFIX: this used to also run from resetAll() on every Reset/"New Search",
1264
+ // per its own now-removed claim that doing so kept the experience
1265
+ // "consistent." It didn't — the function re-opaques the full-screen
1266
+ // #hero-bg-mask and re-hides every .hero-reveal element (inputs, tagline,
1267
+ // random button) for the FULL ~4.3s animation duration before its
1268
+ // `animationend` handler reveals them again. Replaying that on every reset
1269
+ // meant the entire screen went solid-background with nothing visible or
1270
+ // interactive for several seconds — reported as "the page going blank" when
1271
+ // tapping New Search, especially noticeable on mobile. resetAll() now calls
1272
+ // the lightweight resetHeroDisplay() below instead, which shows everything
1273
+ // immediately with no replay.
1274
+ // Each call:
1275
+ // 1. Re-hides the rest of the hero (.hero-reveal) and re-opaques the
1276
+ // background mask (#hero-bg-mask) — necessary since this is the very
1277
+ // first paint and both start in their "revealed" CSS state otherwise.
1278
+ // 2. Measures the title's actual landing position and computes the
1279
+ // exact pixel offset needed to land it on the true viewport center
1280
+ // (a fixed vh value over/undershoots depending on monitor height —
1281
+ // the title's landing position is a fixed-px distance from center,
1282
+ // not a viewport-relative one).
1283
+ // 3. Forces a reflow and re-adds the `entrance` class so the CSS
1284
+ // animation restarts cleanly even if it was already played once.
1285
+ function playHeroEntrance() {
1286
+ const heroTitle = document.getElementById('hero-title');
1287
+ const mask = document.getElementById('hero-bg-mask');
1288
+
1289
+ document.querySelectorAll('.hero-reveal').forEach(el => el.classList.remove('show'));
1290
+ if (mask) mask.classList.remove('hide');
1291
+
1292
+ heroTitle.classList.remove('entrance');
1293
+ const rect = heroTitle.getBoundingClientRect();
1294
+ const dy = window.innerHeight / 2 - (rect.top + rect.height / 2);
1295
+ // The grown stage's transform is `scale(ENTRANCE_SCALE) translateY(...)` —
1296
+ // translateY is applied first and then amplified by the scale that wraps
1297
+ // it, so the on-screen displacement ends up as dy * ENTRANCE_SCALE, not
1298
+ // dy. Pre-dividing here is what makes the title land exactly on center
1299
+ // instead of overshooting by 45%. Must match the scale() value in the
1300
+ // title-entrance keyframes (styles.css).
1301
+ const ENTRANCE_SCALE = 1.45;
1302
+ heroTitle.style.setProperty('--center-dy', `${Math.round(dy / ENTRANCE_SCALE)}px`);
1303
+
1304
+ void heroTitle.offsetWidth; // force reflow so the animation restarts from 0%
1305
+
1306
+ heroTitle.classList.add('entrance');
1307
+ heroTitle.addEventListener('animationend', () => {
1308
+ document.querySelectorAll('.hero-reveal').forEach(el => el.classList.add('show'));
1309
+ if (mask) mask.classList.add('hide');
1310
+ startPlaceholderRotation();
1311
+ // Show onboarding on first visit only
1312
+ if (!localStorage.getItem('aurelius_seen_onboarding')) {
1313
+ showOnboarding();
1314
+ }
1315
+ }, { once: true });
1316
+ }
1317
+
1318
+ // Lightweight hero reset for return visits (Reset / New Search) — shows
1319
+ // everything immediately instead of replaying playHeroEntrance()'s ~4.3s
1320
+ // title-grow-to-center-and-back sequence with its full-screen mask. See the
1321
+ // BUGFIX note on playHeroEntrance() above for why that replay was wrong here.
1322
+ function resetHeroDisplay() {
1323
+ const heroTitle = document.getElementById('hero-title');
1324
+ const mask = document.getElementById('hero-bg-mask');
1325
+ heroTitle.classList.remove('entrance');
1326
+ heroTitle.style.opacity = '1';
1327
+ heroTitle.style.transform = 'scale(1)';
1328
+ if (mask) mask.classList.add('hide');
1329
+ document.querySelectorAll('.hero-reveal').forEach(el => el.classList.add('show'));
1330
+ startPlaceholderRotation();
1331
+ }
1332
+
1333
+ function showOnboarding() {
1334
+ const modal = document.getElementById('onboarding-modal');
1335
+ modal.classList.add('visible');
1336
+ }
1337
+
1338
+ function closeOnboarding() {
1339
+ const modal = document.getElementById('onboarding-modal');
1340
+ modal.classList.remove('visible');
1341
+ localStorage.setItem('aurelius_seen_onboarding', 'true');
1342
+ }
1343
+
1344
+ initLoadingSequence();
common_wiki_searches.txt ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Albert Einstein
2
+ Isaac Newton
3
+ Charles Darwin
4
+ Marie Curie
5
+ Nikola Tesla
6
+ Leonardo da Vinci
7
+ William Shakespeare
8
+ Wolfgang Amadeus Mozart
9
+ Ludwig van Beethoven
10
+ Vincent van Gogh
11
+ Pablo Picasso
12
+ Sigmund Freud
13
+ Karl Marx
14
+ Adolf Hitler
15
+ Winston Churchill
16
+ Abraham Lincoln
17
+ George Washington
18
+ Napoleon
19
+ Julius Caesar
20
+ Cleopatra
21
+ Alexander the Great
22
+ Genghis Khan
23
+ Mahatma Gandhi
24
+ Nelson Mandela
25
+ Martin Luther King Jr.
26
+ Barack Obama
27
+ Donald Trump
28
+ Elon Musk
29
+ Bill Gates
30
+ Steve Jobs
31
+ Mark Zuckerberg
32
+ Jeff Bezos
33
+ Warren Buffett
34
+ Stephen Hawking
35
+ Carl Sagan
36
+ Richard Feynman
37
+ Alan Turing
38
+ John von Neumann
39
+ Ada Lovelace
40
+ Tim Berners-Lee
41
+ Linus Torvalds
42
+ World War I
43
+ World War II
44
+ Cold War
45
+ French Revolution
46
+ American Revolution
47
+ Russian Revolution
48
+ Industrial Revolution
49
+ Renaissance
50
+ Roman Empire
51
+ Byzantine Empire
52
+ Ottoman Empire
53
+ British Empire
54
+ Ancient Egypt
55
+ Ancient Greece
56
+ Ancient Rome
57
+ Holocaust
58
+ September 11 attacks
59
+ Cuban Missile Crisis
60
+ Vietnam War
61
+ Korean War
62
+ Apollo 11
63
+ Moon landing
64
+ Big Bang
65
+ Solar System
66
+ Earth
67
+ Sun
68
+ Moon
69
+ Mars
70
+ Jupiter
71
+ Saturn
72
+ Black hole
73
+ Galaxy
74
+ Universe
75
+ Quantum mechanics
76
+ Theory of relativity
77
+ Evolution
78
+ DNA
79
+ Human genome
80
+ Photosynthesis
81
+ Gravity
82
+ Electricity
83
+ Magnetism
84
+ Periodic table
85
+ Chemical element
86
+ Atom
87
+ Molecule
88
+ Climate change
89
+ Global warming
90
+ Renewable energy
91
+ Solar power
92
+ Nuclear power
93
+ Artificial intelligence
94
+ Machine learning
95
+ Deep learning
96
+ Neural network
97
+ Internet
98
+ World Wide Web
99
+ Computer
100
+ Smartphone
101
+ Programming language
102
+ Python (programming language)
103
+ JavaScript
104
+ Java (programming language)
105
+ C++
106
+ Linux
107
+ Microsoft Windows
108
+ Android (operating system)
109
+ iOS
110
+ Google
111
+ Apple Inc.
112
+ Microsoft
113
+ Amazon (company)
114
+ Facebook
115
+ Meta Platforms
116
+ Twitter
117
+ Tesla, Inc.
118
+ SpaceX
119
+ OpenAI
120
+ Wikipedia
121
+ YouTube
122
+ Netflix
123
+ Spotify
124
+ United States
125
+ United Kingdom
126
+ France
127
+ Germany
128
+ Italy
129
+ Spain
130
+ Russia
131
+ China
132
+ Japan
133
+ India
134
+ Pakistan
135
+ Bangladesh
136
+ Brazil
137
+ Mexico
138
+ Canada
139
+ Australia
140
+ South Africa
141
+ Egypt
142
+ Nigeria
143
+ Saudi Arabia
144
+ Israel
145
+ Iran
146
+ Iraq
147
+ Turkey
148
+ Greece
149
+ Poland
150
+ Ukraine
151
+ Sweden
152
+ Norway
153
+ Finland
154
+ Switzerland
155
+ Netherlands
156
+ Belgium
157
+ Portugal
158
+ South Korea
159
+ North Korea
160
+ Vietnam
161
+ Thailand
162
+ Indonesia
163
+ Philippines
164
+ Malaysia
165
+ Singapore
166
+ New Zealand
167
+ Argentina
168
+ Chile
169
+ Colombia
170
+ Peru
171
+ Venezuela
172
+ Cuba
173
+ New York City
174
+ Los Angeles
175
+ London
176
+ Paris
177
+ Tokyo
178
+ Beijing
179
+ Mumbai
180
+ Delhi
181
+ Moscow
182
+ Berlin
183
+ Rome
184
+ Madrid
185
+ Sydney
186
+ Toronto
187
+ Dubai
188
+ Singapore (city)
189
+ Hong Kong
190
+ Shanghai
191
+ Cairo
192
+ Istanbul
193
+ Bangkok
194
+ Seoul
195
+ Mohali
196
+ Chandigarh
197
+ Punjab, India
198
+ Maharashtra
199
+ Bangalore
200
+ Chennai
201
+ Kolkata
202
+ Hyderabad
203
+ Pune
204
+ Ahmedabad
205
+ Jaipur
206
+ Lucknow
207
+ Nagpur
208
+ Football
209
+ Basketball
210
+ Cricket
211
+ Tennis
212
+ Baseball
213
+ Olympic Games
214
+ FIFA World Cup
215
+ Cricket World Cup
216
+ National Basketball Association
217
+ Premier League
218
+ UEFA Champions League
219
+ Lionel Messi
220
+ Cristiano Ronaldo
221
+ Michael Jordan
222
+ LeBron James
223
+ Usain Bolt
224
+ Serena Williams
225
+ Roger Federer
226
+ Muhammad Ali
227
+ Pelé
228
+ Diego Maradona
229
+ Music
230
+ Rock music
231
+ Pop music
232
+ Hip hop music
233
+ Jazz
234
+ Classical music
235
+ The Beatles
236
+ Elvis Presley
237
+ Michael Jackson
238
+ Bob Dylan
239
+ Taylor Swift
240
+ Beyoncé
241
+ Film
242
+ Hollywood
243
+ Bollywood
244
+ Academy Awards
245
+ Star Wars
246
+ The Lord of the Rings (film series)
247
+ Harry Potter
248
+ Marvel Cinematic Universe
249
+ Television
250
+ Streaming media
251
+ Video game
252
+ Minecraft
253
+ Fortnite (video game)
254
+ Chess
255
+ Literature
256
+ Novel
257
+ Poetry
258
+ The Bible
259
+ Quran
260
+ Religion
261
+ Christianity
262
+ Islam
263
+ Hinduism
264
+ Buddhism
265
+ Judaism
266
+ Sikhism
267
+ Atheism
268
+ Philosophy
269
+ Plato
270
+ Aristotle
271
+ Socrates
272
+ Immanuel Kant
273
+ Friedrich Nietzsche
274
+ Confucius
275
+ Logic
276
+ Ethics
277
+ Mathematics
278
+ Algebra
279
+ Geometry
280
+ Calculus
281
+ Statistics
282
+ Probability
283
+ Number theory
284
+ Physics
285
+ Chemistry
286
+ Biology
287
+ Astronomy
288
+ Geology
289
+ Psychology
290
+ Sociology
291
+ Economics
292
+ Capitalism
293
+ Socialism
294
+ Communism
295
+ Democracy
296
+ Government
297
+ United Nations
298
+ World Health Organization
299
+ NASA
300
+ European Union
301
+ NATO
302
+ World Bank
303
+ International Monetary Fund
304
+ Stock market
305
+ Cryptocurrency
306
+ Bitcoin
307
+ Ethereum
308
+ Inflation
309
+ Recession
310
+ Great Depression
311
+ 2008 financial crisis
312
+ COVID-19 pandemic
313
+ Vaccine
314
+ Antibiotic
315
+ Cancer
316
+ Heart disease
317
+ Diabetes
318
+ Mental health
319
+ Depression (mood)
320
+ Anxiety
321
+ Nutrition
322
+ Exercise
323
+ Sleep
324
+ Human body
325
+ Brain
326
+ Heart
327
+ Immune system
328
+ Bacteria
329
+ Virus
330
+ Medicine
331
+ Surgery
332
+ Hospital
333
+ Doctor of Medicine
334
+ Nursing
335
+ Pharmacy
336
+ Veterinary medicine
337
+ Agriculture
338
+ Food
339
+ Water
340
+ Air pollution
341
+ Plastic pollution
342
+ Deforestation
343
+ Biodiversity
344
+ Endangered species
345
+ Tiger
346
+ Lion
347
+ Elephant
348
+ Whale
349
+ Shark
350
+ Dolphin
351
+ Dog
352
+ Cat
353
+ Horse
354
+ Bird
355
+ Dinosaur
356
+ Fossil
357
+ Evolutionary biology
358
+ Natural selection
359
+ Ecosystem
360
+ Rainforest
361
+ Desert
362
+ Ocean
363
+ Mountain
364
+ River
365
+ Volcano
366
+ Earthquake
367
+ Tsunami
368
+ Hurricane
369
+ Tornado
370
+ Weather
371
+ Climate
372
+ Season
373
+ Time zone
374
+ Calendar
375
+ History of the world
376
+ Prehistory
377
+ Stone Age
378
+ Bronze Age
379
+ Iron Age
380
+ Middle Ages
381
+ Crusades
382
+ Black Death
383
+ Silk Road
384
+ Colonialism
385
+ Slavery
386
+ Civil rights movement
387
+ Feminism
388
+ LGBT rights
389
+ Human rights
390
+ United States Constitution
391
+ Declaration of Independence
392
+ Magna Carta
393
+ Law
394
+ Crime
395
+ Police
396
+ Prison
397
+ Supreme Court of the United States
398
+ United States Congress
399
+ President of the United States
400
+ Prime Minister
401
+ Monarchy
402
+ Dictatorship
403
+ Election
404
+ Voting
405
+ Political party
406
+ Nationalism
407
+ Globalization
408
+ Trade
409
+ Tariff
410
+ Currency
411
+ United States dollar
412
+ Euro
413
+ Gold
414
+ Oil
415
+ Coal
416
+ Natural gas
417
+ Electric vehicle
418
+ Automobile
419
+ Aircraft
420
+ Airplane
421
+ Boeing
422
+ Airbus
423
+ Ship
424
+ Train
425
+ Bicycle
426
+ Bridge
427
+ Skyscraper
428
+ Architecture
429
+ Eiffel Tower
430
+ Statue of Liberty
431
+ Great Wall of China
432
+ Taj Mahal
433
+ Pyramids of Giza
434
+ Colosseum
435
+ Stonehenge
436
+ Machu Picchu
437
+ Petra
438
+ Angkor Wat
439
+ Mount Everest
440
+ Amazon rainforest
441
+ Sahara
442
+ Antarctica
443
+ Arctic
444
+ Pacific Ocean
445
+ Atlantic Ocean
446
+ Mediterranean Sea
447
+ Nile
448
+ Amazon River
449
+ Yangtze
450
+ Himalayas
451
+ Andes
452
+ Alps
453
+ Grand Canyon
454
+ Niagara Falls
455
+ Yellowstone National Park
456
+ Education
457
+ University
458
+ Harvard University
459
+ Stanford University
460
+ Massachusetts Institute of Technology
461
+ Oxford University
462
+ Cambridge University
463
+ Library
464
+ School
465
+ Language
466
+ English language
467
+ Spanish language
468
+ Mandarin Chinese
469
+ Arabic
470
+ Hindi
471
+ French language
472
+ German language
473
+ Translation
474
+ Linguistics
475
+ Alphabet
476
+ Writing system
477
+ Printing press
478
+ Photography
479
+ Telephone
480
+ Radio
481
+ Television (technology)
482
+ Satellite
483
+ GPS
484
+ Robot
485
+ Automation
486
+ 3D printing
487
+ Biotechnology
488
+ Genetic engineering
489
+ CRISPR
490
+ Stem cell
491
+ Cloning
492
+ Space exploration
493
+ International Space Station
494
+ Hubble Space Telescope
495
+ Mars rover
496
+ Asteroid
497
+ Comet
498
+ Meteor
499
+ Exoplanet
500
+ Astrobiology
501
+ String theory
502
+ Dark matter
503
+ Dark energy
504
+ Higgs boson
505
+ Large Hadron Collider
506
+ Periodic table of elements
507
+ Hydrogen
508
+ Oxygen
509
+ Carbon
510
+ Gold (element)
511
+ Iron
512
+ Uranium
513
+ Plutonium
514
+ Nuclear weapon
515
+ World population
516
+ Urbanization
517
+ Migration
518
+ Refugee
519
+ Poverty
520
+ Famine
521
+ War
522
+ Terrorism
523
+ Genocide
524
+ Nuclear weapons testing
525
+ Space Race
526
+ Berlin Wall
527
+ Iron Curtain
528
+ Apartheid
529
+ Tiananmen Square protests
530
+ Arab Spring
531
+ Brexit
532
+ European migrant crisis
533
+ Russian invasion of Ukraine
534
+ Israeli–Palestinian conflict
535
+ Climate justice
536
+ Sustainable development
537
+ United Nations Sustainable Development Goals
538
+ Renewable resource
539
+ Fossil fuel
540
+ Carbon footprint
541
+ Recycling
542
+ Plastic
543
+ Pandemic
544
+ Epidemiology
545
+ Public health
546
+ Mental illness
547
+ Autism
548
+ ADHD
549
+ Alzheimer's disease
550
+ Parkinson's disease
551
+ HIV/AIDS
552
+ Tuberculosis
553
+ Malaria
554
+ Smallpox
555
+ Influenza
556
+ Spanish flu
config.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aurelius — central configuration.
3
+
4
+ All tunables live here so they can be adjusted (or env-overridden) without
5
+ touching the search/embedding/wiki/server logic. See CLAUDE.md and the
6
+ architect plan (Production Refactor + Algorithm + UI Pass, §2) for context.
7
+ """
8
+
9
+ import os
10
+ from pathlib import Path
11
+
12
+ # ── Embedding model ──────────────────────────────────────────────────────
13
+ # Default is all-MiniLM-L6-v2: 80 MB / 384-dim, fits free-tier deployment
14
+ # (Render free web service) without OOMing or blowing the cold-start budget.
15
+ # Qwen/Qwen3-Embedding-0.6B (plan §3) is a better-quality drop-in if you're
16
+ # running on a host with more RAM — set AURELIUS_EMBED_MODEL to switch.
17
+ EMBED_MODEL_NAME = os.getenv("AURELIUS_EMBED_MODEL", "all-MiniLM-L6-v2")
18
+ EMBED_DEVICE = os.getenv("AURELIUS_EMBED_DEVICE", "cpu")
19
+ EMBED_BATCH_SIZE = int(os.getenv("AURELIUS_EMBED_BATCH", "64")) # smaller for 0.6B vs 128 for MiniLM
20
+
21
+ # ── Wikipedia ────────────────────────────────────────────────────────────
22
+ WIKI_API = "https://en.wikipedia.org/w/api.php"
23
+ MAX_FETCH = 500 # max links fetched from Wikipedia (paginated)
24
+ MAX_PAGES = 6 # safety cap on pagination pages when hunting for a specific goal link
25
+ # Wikimedia's robot policy (https://w.wiki/4wJS) 403s requests whose
26
+ # User-Agent has no contact info — this was dropped during the module split
27
+ # and the bare "Aurelius-WikiNavigator/7.0" string started getting blocked.
28
+ WIKI_HEADERS = {
29
+ "User-Agent": "Aurelius-WikiNavigator/7.0 (educational project; contact: murtaza.vali.ug25@plaksha.edu.in)",
30
+ "Accept": "application/json",
31
+ }
32
+
33
+ # ── Search ───────────────────────────────────────────────────────────────
34
+ MAX_HOPS = 60 # enforced as expansion-count cap, not path-length cap (see search.py)
35
+ TOP_DISPLAY = 25 # max neighbours sent to frontend per step
36
+
37
+ # ── Scoring ──────────────────────────────────────────────────────────────
38
+ # Architect plan (Honest Bidirectional Meeting-Check Rewrite): the target's
39
+ # backlinks (depth-1) are now the actual goal zone, not just a score hint —
40
+ # see search.py's meeting check. Depth-2 frontier, its bonus, and the
41
+ # category bonus were removed: none of them could contribute a real,
42
+ # verifiable edge, and depth-2 specifically could not be stitched into a
43
+ # real path without bridge bookkeeping it never had. The stagnation/
44
+ # frontier-jump escape valve was removed entirely — it fabricated a
45
+ # came_from pointer with no corresponding Wikipedia link (see the Marcus
46
+ # Aurelius -> A* search algorithm incident), which is no longer needed now
47
+ # that the meeting check gives the search an honest way to converge.
48
+ PRUNE_TOP_K = 15 # keep this many candidates per expansion after scoring
49
+ # NOTE: tried raising this to 0.22 as part of the Semantic Drift fix (see
50
+ # search.py docstring) on the theory that richer "{title}. {short_desc}"
51
+ # embeddings would make raw cosine trustworthy enough to support a higher
52
+ # floor. Verified live against Marcus Aurelius -> A* search algorithm: the
53
+ # best of Marcus Aurelius's 500 direct links scores only ~0.186 raw cosine
54
+ # to the (also-enriched) target embedding — there is no semantically close
55
+ # 1-hop neighbour for a niche CS topic from a Roman-emperor article, full
56
+ # stop, regardless of embedding quality. 0.22 killed the search at step 1
57
+ # (0/500 candidates survived). Kept at 0.15. The actual drift fix is the
58
+ # embedding enrichment improving *relative* ranking among survivors, plus
59
+ # DEPTH_TIEBREAK_EPSILON preventing runaway commitment to one irrelevant
60
+ # cluster — not an absolute floor, which hard multi-hop cases can't clear
61
+ # early on by construction.
62
+ PRUNE_FLOOR = 0.15 # soft floor on raw cosine-to-target; frontier members exempt
63
+ PRUNE_MIN_SURVIVORS = 5 # ALWAYS keep at least this many top-ranked candidates per step,
64
+ # even if they fall below PRUNE_FLOOR — prevents the search from
65
+ # killing itself at step 1 for semantically distant pairs
66
+ # (Cleopatra → Time complexity: 0/500 candidates cleared 0.15,
67
+ # search died immediately)
68
+ FRONTIER_D1_BONUS = 0.30 # candidate is a direct backlink of the target (goal-zone member)
69
+
70
+ # Depth tie-breaker (Semantic Drift fix): heap priority is h + DEPTH_EPSILON*g
71
+ # instead of pure h. This is a deliberate, documented reversal of the prior
72
+ # "heap priority = h only, no g-cost" design (see CLAUDE.md) — pure-greedy
73
+ # let one noisy high-scoring title (e.g. a proper noun with no domain
74
+ # context) drag the search arbitrarily deep into its cluster with nothing
75
+ # to prefer a shallower, more recently-improving alternative. 0.01 is small
76
+ # enough that it only breaks ties/near-ties; it doesn't override a genuinely
77
+ # strong h advantage at any reasonable depth (60 hops -> max 0.6 penalty).
78
+ DEPTH_TIEBREAK_EPSILON = 0.01
79
+
80
+ # ── Active Backward Expansion (goal-zone depth-2) ───────────────────────
81
+ GOAL_ZONE_EXPAND_INTERVAL = 10 # expand goal zone every N steps
82
+ GOAL_ZONE_EXPAND_BATCH = 5 # d1 members to expand per interval
83
+ GOAL_ZONE_D2_BACKLINK_LIMIT = 100 # max backlinks fetched per d1 member
84
+ FRONTIER_D2_BONUS = 0.15 # score bonus for d2 goal-zone members (half of d1's 0.30)
85
+
86
+ # ── Stagnation Detection / Cluster Escape ────────────────────────────────
87
+ STAGNATION_WINDOW = 8 # sliding window size for h-improvement tracking
88
+ STAGNATION_DELTA = 0.02 # min improvement over window to count as progress
89
+ PRUNE_TOP_K_STAGNANT = 25 # widened beam when stagnant (vs 15 normal)
90
+ DIVERSITY_WEIGHT = 0.10 # weight for centroid-distance diversity bonus
91
+
92
+ # ── Files ────────────────────────────────────────────────────────────────
93
+ _THIS_DIR = Path(__file__).resolve().parent
94
+ SEED_FILE = _THIS_DIR / "common_wiki_searches.txt"
embedding.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aurelius — embedding model singleton, batch_embed, cosine, session caches.
3
+
4
+ Implements architect plan §3 (embedding swap to Qwen3-Embedding-0.6B) and
5
+ part of §1 (module split: model singleton + caches live here, owned by the
6
+ module that uses them, per the plan's "Module-level globals ... follow
7
+ their owning module" instruction).
8
+ """
9
+
10
+ import asyncio
11
+ import os
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ # Once the model has been downloaded once, sentence-transformers/huggingface_hub
17
+ # still spend 1-3s on every startup doing a network round-trip to check for
18
+ # updates. These must be set before `sentence_transformers` is imported, and
19
+ # only skip the check if the model is already cached locally (first run still
20
+ # goes online to fetch it).
21
+ #
22
+ # Architect plan §3: widen the glob from "models--sentence-transformers--*"
23
+ # to "models--*" so this still kicks in for the new Qwen3 model (whose cache
24
+ # dir is "models--Qwen--Qwen3-Embedding-0.6B", not "models--sentence-transformers--*").
25
+ # Still safe — it only activates when *any* cached model exists locally.
26
+ _hf_cache = Path.home() / ".cache" / "huggingface" / "hub"
27
+ if _hf_cache.exists() and any(_hf_cache.glob("models--*")):
28
+ os.environ.setdefault("HF_HUB_OFFLINE", "1")
29
+ os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
30
+
31
+ import numpy as np
32
+ from sentence_transformers import SentenceTransformer
33
+
34
+ from config import EMBED_MODEL_NAME, EMBED_DEVICE, EMBED_BATCH_SIZE
35
+
36
+ # ══════════════════════════════════════════════════════════════
37
+ # MODEL SINGLETON — loaded once at startup, in-process
38
+ # ══════════════════════════════════════════════════════════════
39
+
40
+ _EMBED_MODEL: Optional[SentenceTransformer] = None
41
+
42
+
43
+ async def load_model():
44
+ """
45
+ Startup handler: loads the embedding model once, in-process.
46
+
47
+ device="cpu" skips torch's CUDA-availability probe (driver/nvidia-smi
48
+ calls), which otherwise adds 1-2s on machines without a fast GPU setup.
49
+
50
+ No silent fallback to MiniLM on failure (architect plan §3) — if the
51
+ download or load fails, this re-raises so FastAPI refuses connections
52
+ rather than degrading silently.
53
+ """
54
+ global _EMBED_MODEL
55
+ print(f"[Embed] Loading sentence-transformers model '{EMBED_MODEL_NAME}'...")
56
+ t0 = time.time()
57
+ _EMBED_MODEL = SentenceTransformer(EMBED_MODEL_NAME, device=EMBED_DEVICE)
58
+ print(f"[Embed] {EMBED_MODEL_NAME} ready "
59
+ f"(dim={_EMBED_MODEL.get_embedding_dimension()}, {time.time()-t0:.1f}s)")
60
+
61
+
62
+ # ══════════════════════════════════════════════════════════════
63
+ # SESSION CACHES (reset each search)
64
+ # ══════════════════════════════════════════════════════════════
65
+
66
+ _emb_cache: dict[str, np.ndarray] = {} # title → embedding vector
67
+ _dead_ends: set[str] = set() # articles with 0 links
68
+
69
+
70
+ def reset_session_caches():
71
+ global _emb_cache, _dead_ends
72
+ _emb_cache = {}
73
+ _dead_ends = set()
74
+
75
+
76
+ # ══════════════════════════════════════════════════════════════
77
+ # VECTOR MATH
78
+ # ══════════════════════════════════════════════════════════════
79
+
80
+ def cosine_similarity(a, b) -> float:
81
+ if a is None or b is None:
82
+ return 0.0
83
+ a = np.asarray(a, dtype=np.float32)
84
+ b = np.asarray(b, dtype=np.float32)
85
+ if a.size == 0 or b.size == 0:
86
+ return 0.0
87
+ na = np.linalg.norm(a)
88
+ nb = np.linalg.norm(b)
89
+ if na == 0 or nb == 0:
90
+ return 0.0
91
+ return float(np.dot(a, b) / (na * nb))
92
+
93
+
94
+ # ══════════════════════════════════════════════════════════════
95
+ # EMBEDDING (in-process sentence-transformers, no network calls)
96
+ # ══════════════════════════════════════════════════════════════
97
+
98
+ async def batch_embed(texts: list[str], stats,
99
+ embed_texts: list[str] | None = None) -> list[np.ndarray]:
100
+ """
101
+ Embed texts via the in-process sentence-transformers model.
102
+ Results are cached per title across the session. Runs the (CPU-bound,
103
+ synchronous) encode call in a thread executor so it never blocks the
104
+ event loop — keeps pause/resume and other WS traffic responsive.
105
+
106
+ `embed_texts`, if given, is what actually gets encoded (parallel to
107
+ `texts`, which remains the cache key). Lets callers embed a richer
108
+ string — e.g. "Anu. Mesopotamian sky-god" — while still caching and
109
+ looking up by the bare title "Anu". Bare-title-only embedding can't
110
+ tell a proper noun's actual subject apart from the target's, which is
111
+ what let semantically unrelated link-adjacent pages (mythology,
112
+ scripture, etc.) survive scoring on noisy title-only cosine alone.
113
+
114
+ `stats` is a SearchStats instance (search.py); only its embed_calls
115
+ counter is touched here, so no import of search.py is needed (keeps
116
+ embedding.py free of any dependency on search.py).
117
+ """
118
+ if not texts or _EMBED_MODEL is None:
119
+ return [np.array([]) for _ in texts]
120
+ if embed_texts is None:
121
+ embed_texts = texts
122
+
123
+ uncached_idx = [i for i, t in enumerate(texts) if t not in _emb_cache]
124
+ uncached_text = [embed_texts[i] for i in uncached_idx]
125
+ if uncached_text:
126
+ loop = asyncio.get_running_loop()
127
+ embs = await loop.run_in_executor(
128
+ None,
129
+ lambda: _EMBED_MODEL.encode(
130
+ uncached_text, convert_to_numpy=True,
131
+ batch_size=EMBED_BATCH_SIZE, show_progress_bar=False,
132
+ ),
133
+ )
134
+ stats.embed_calls += 1
135
+ for orig_i, emb in zip(uncached_idx, embs):
136
+ _emb_cache[texts[orig_i]] = emb
137
+
138
+ return [_emb_cache.get(t, np.array([])) for t in texts]
htmlbackup.txt ADDED
The diff for this file is too large to render. See raw diff
 
index.html ADDED
@@ -0,0 +1,498 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Aurelius</title>
8
+ <link rel="icon" type="image/png"
9
+ href="data:image/png;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAUDBAQEAwUEBAQFBQUGBwwIBwcHBw8LCwkMEQ8SEhEPERETFhwXExQaFRERGCEYGh0dHx8fExciJCIeJBweHx7/2wBDAQUFBQcGBw4ICA4eFBEUHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh7/wAARCAH0AfQDASIAAhEBAxEB/8QAHQABAAICAwEBAAAAAAAAAAAAAAYHBAgBAwUCCf/EAEsQAAIBAwIEAwQGBwQHBwUBAAABAgMEBQYRByExQRJRYQgTcYEUIjKRobEVI0JSksHTM1Vi8SRjcnWisvAXNEZTgsLRFjVDs9Lh/8QAGwEBAAIDAQEAAAAAAAAAAAAAAAEDAgQFBgf/xAAuEQEAAgIBAgMHBQEAAwAAAAAAAQIDEQQFMRMhQQYSUXGBobEyM1Jh0SIVkfD/2gAMAwEAAhEDEQA/ANMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALS4Q8HsrrSMMpkZzx2E35Vdv1tfb/AMtNbbf4ny9GVZs1MNJvknUQuwYMnIvGPHG5lVoN49L8N9FadtoUrHT1jUqRW30i5pRq1ZPz8Uk9vlsj2rrT+BvKLo3eExtxSa2cKtrCa2+aOHb2j48W1WszD0FPZfkzXc2iJaBA2q4i8BNPZehWu9MJYjIbNxpbv6NN+TXN…[truncated base64 icon]">
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
11
+ <link rel="preconnect" href="https://fonts.googleapis.com">
12
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13
+ <link
14
+ href="https://fonts.googleapis.com/css2?family=Cinzel:wght@700;900&family=Outfit:wght@300;400;500;600;700&family=DM+Mono:ital,wght@0,400;0,500;1,400&display=swap"
15
+ rel="stylesheet">
16
+ <link rel="stylesheet" href="styles.css">
17
+ </head>
18
+
19
+ <body>
20
+
21
+ <!-- ═══════════════════ LOADING SCREEN ═══════════════════ -->
22
+ <div id="loading-screen">
23
+ <div id="loading-logo">Aurelius</div>
24
+ <div id="loading-spinner"></div>
25
+ </div>
26
+
27
+ <!-- ═══════════════════ HERO LANDING ═══════════════════ -->
28
+ <div id="hero">
29
+ <div id="hero-watermark">
30
+ <svg class="aurelius-logo-hero" viewBox="0 0 200 200" aria-hidden="true">
31
+ <defs>
32
+ <radialGradient id="wm-glow" cx="50%" cy="50%" r="50%">
33
+ <stop offset="0%" stop-color="rgba(201,138,46,0.10)" />
34
+ <stop offset="100%" stop-color="rgba(201,138,46,0)" />
35
+ </radialGradient>
36
+ </defs>
37
+ <circle cx="100" cy="100" r="90" fill="url(#wm-glow)" />
38
+ <g class="wm-edges">
39
+ <line x1="100" y1="28" x2="158" y2="52" />
40
+ <line x1="158" y1="52" x2="172" y2="108" />
41
+ <line x1="172" y1="108" x2="142" y2="158" />
42
+ <line x1="142" y1="158" x2="100" y2="176" />
43
+ <line x1="100" y1="176" x2="58" y2="158" />
44
+ <line x1="58" y1="158" x2="28" y2="108" />
45
+ <line x1="28" y1="108" x2="42" y2="52" />
46
+ <line x1="42" y1="52" x2="100" y2="28" />
47
+ <line x1="100" y1="28" x2="100" y2="100" />
48
+ <line x1="158" y1="52" x2="100" y2="100" />
49
+ <line x1="172" y1="108" x2="100" y2="100" />
50
+ <line x1="58" y1="158" x2="100" y2="100" />
51
+ <line x1="42" y1="52" x2="100" y2="100" />
52
+ <line x1="28" y1="108" x2="58" y2="158" />
53
+ <line x1="42" y1="52" x2="158" y2="52" />
54
+ <line x1="142" y1="158" x2="58" y2="158" />
55
+ <line x1="100" y1="28" x2="142" y2="158" />
56
+ <line x1="42" y1="52" x2="172" y2="108" />
57
+ </g>
58
+ <polyline class="wm-path-trace" points="42,52 100,100 158,52 172,108 142,158" />
59
+ <polyline class="wm-path-trace wm-trace-2" points="100,28 100,100 58,158 100,176" />
60
+ <g class="wm-nodes">
61
+ <circle cx="100" cy="28" r="4.5" />
62
+ <circle cx="158" cy="52" r="4.5" />
63
+ <circle cx="172" cy="108" r="4.5" />
64
+ <circle cx="142" cy="158" r="4.5" />
65
+ <circle cx="100" cy="176" r="4.5" />
66
+ <circle cx="58" cy="158" r="4.5" />
67
+ <circle cx="28" cy="108" r="4.5" />
68
+ <circle cx="42" cy="52" r="4.5" />
69
+ </g>
70
+ <circle class="wm-hub" cx="100" cy="100" r="6.5" />
71
+ <circle class="wm-particle wm-p1" r="1.5" />
72
+ <circle class="wm-particle wm-p2" r="1.2" />
73
+ <circle class="wm-particle wm-p3" r="1.8" />
74
+ <circle class="wm-particle wm-p4" r="1" />
75
+ </svg>
76
+ </div>
77
+ <div id="hero-bg-mask"></div>
78
+ <button id="hero-about-btn" class="hero-reveal" onclick="openAboutModal()">About</button>
79
+ <div id="hero-title" style="font-family: Cinzel">Aurelius</div>
80
+ <div id="hero-tagline" class="hero-reveal">Navigate the Knowledge Graph</div>
81
+ <div id="hero-rest" class="hero-reveal">
82
+ <div id="hero-sub">Find the hidden path between any two Wikipedia articles.<br>Watch Aurelius navigate the world's
83
+ largest knowledge graph in real time.</div>
84
+
85
+ <div id="hero-inputs">
86
+ <div class="hero-field">
87
+ <label id="lbl-start" for="hero-inp-start">From</label>
88
+ <input id="hero-inp-start" class="hero-input" type="text" autocomplete="on"
89
+ placeholder="e.g. Nagpur" autocomplete="off" oninput="heroInputChanged()" onkeydown="heroKeyDown(event)" />
90
+ <div id="ac-dropdown-start" class="ac-dropdown"></div>
91
+ </div>
92
+
93
+ <div id="hero-arrow">→</div>
94
+
95
+ <div class="hero-field">
96
+ <label id="lbl-end" for="hero-inp-end">To</label>
97
+ <input id="hero-inp-end" class="hero-input" type="text" autocomplete="off" placeholder="e.g. Mars"
98
+ oninput="heroInputChanged()" onkeydown="heroKeyDown(event)" />
99
+ <div id="ac-dropdown-end" class="ac-dropdown"></div>
100
+ </div>
101
+ </div>
102
+
103
+ <button id="hero-btn" onclick="startSearch()">Find Path</button>
104
+ <div id="hero-hint">Press Enter to search</div>
105
+
106
+ <button id="hero-random-btn" onclick="useRandomPair()" title="Pick two random topics and find a path">
107
+ Random
108
+ </button>
109
+ </div>
110
+ </div>
111
+
112
+ <!-- ═══════════════════ TOP BAR (post-search) ═══════════════════ -->
113
+ <div id="topbar">
114
+ <h1 id="topbar-logo" onclick="logoClick()" title="Return to home">
115
+ <svg class="topbar-logo-img aurelius-logo" viewBox="0 0 100 100" aria-hidden="true">
116
+ <g class="alogo-edges" opacity="0.5">
117
+ <line x1="50" y1="14" x2="79" y2="26" />
118
+ <line x1="79" y1="26" x2="86" y2="54" />
119
+ <line x1="86" y1="54" x2="71" y2="79" />
120
+ <line x1="71" y1="79" x2="50" y2="88" />
121
+ <line x1="50" y1="88" x2="29" y2="79" />
122
+ <line x1="29" y1="79" x2="14" y2="54" />
123
+ <line x1="14" y1="54" x2="21" y2="26" />
124
+ <line x1="21" y1="26" x2="50" y2="14" />
125
+ <line x1="50" y1="14" x2="50" y2="50" />
126
+ <line x1="79" y1="26" x2="50" y2="50" />
127
+ <line x1="21" y1="26" x2="50" y2="50" />
128
+ <line x1="86" y1="54" x2="50" y2="50" />
129
+ <line x1="29" y1="79" x2="50" y2="50" />
130
+ </g>
131
+ <polyline class="alogo-trace" points="21,26 50,50 79,26 86,54 71,79" />
132
+ <g class="alogo-nodes">
133
+ <circle cx="50" cy="14" r="5" />
134
+ <circle cx="79" cy="26" r="5" />
135
+ <circle cx="86" cy="54" r="5" />
136
+ <circle cx="71" cy="79" r="5" />
137
+ <circle cx="50" cy="88" r="5" />
138
+ <circle cx="29" cy="79" r="5" />
139
+ <circle cx="14" cy="54" r="5" />
140
+ <circle cx="21" cy="26" r="5" />
141
+ </g>
142
+ <circle class="alogo-hub" cx="50" cy="50" r="7" />
143
+ </svg>
144
+ Aurelius
145
+ </h1>
146
+ <div class="input-wrap">
147
+ <span>FROM</span>
148
+ <input id="inp-start" type="text" placeholder="Article or Wikipedia URL" />
149
+ </div>
150
+ <button id="btn-swap" onclick="swapInputs()" title="Swap start and end">⇅</button>
151
+ <div class="input-wrap">
152
+ <span>TO</span>
153
+ <input id="inp-end" type="text" placeholder="Article or Wikipedia URL" />
154
+ </div>
155
+ <button id="btn-search" onclick="startSearch()">Find Path</button>
156
+ <button id="btn-reset" onclick="resetAll()">Reset</button>
157
+ <button id="btn-about" onclick="openAboutModal()">About</button>
158
+ <span id="status-bar">Ready.</span>
159
+ </div>
160
+
161
+ <!-- Mobile-only: collapses #topbar above to keep the search-controls
162
+ chrome out of the way by default, so a phone screen opens on the
163
+ live graph instead of a cluttered stack. Hidden entirely on
164
+ desktop/tablet (styles.css), where the topbar is always visible. -->
165
+ <div id="mobile-topbar-handle" class="mobile-handle" onclick="toggleMobileTopbar()">
166
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="11" height="11"><polyline points="4 6 8 10 12 6"/></svg>
167
+ <span>Search</span>
168
+ </div>
169
+
170
+ <!-- ═══ STATS BAR (pause/export only — timer shown on completion) ═══ -->
171
+ <div id="stats-bar">
172
+ <div id="sv-steps-live" class="sstat">
173
+ <span class="sstat-val" id="sv-steps-count">0</span> explored
174
+ </div>
175
+ <div class="stats-spacer"></div>
176
+ <button id="btn-export" onclick="exportPath()" title="Export path [E]">↓ Export</button>
177
+ <button id="btn-pause-bar" onclick="togglePause()" title="Pause/Resume [Space]">⏸ Pause</button>
178
+ </div>
179
+
180
+ <!-- ═══════════════════ MAIN CONTENT ═══════════════════ -->
181
+ <div id="main">
182
+ <!-- Graph -->
183
+ <div id="canvas-wrap">
184
+ <svg id="graph"></svg>
185
+ <div id="tooltip">
186
+ <div class="tt-title" id="tt-title"></div>
187
+ <div class="tt-scores" id="tt-scores"></div>
188
+ </div>
189
+ </div>
190
+
191
+ <!-- Mobile-only: toggles the panel below open/closed. Lives OUTSIDE
192
+ #panel (not inside it) so it stays visible and tappable even while
193
+ #panel itself is collapsed to zero height — see styles.css. Hidden
194
+ entirely on desktop/tablet, where the panel is always visible. -->
195
+ <div id="mobile-panel-handle" class="mobile-handle" onclick="toggleMobilePanel()">
196
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="11" height="11"><polyline points="4 6 8 10 12 6"/></svg>
197
+ <span>Path &amp; Details</span>
198
+ </div>
199
+
200
+ <!-- Panel -->
201
+ <div id="panel">
202
+ <div id="panel-tabs">
203
+ <div class="tab active" onclick="switchTab('path')">Path</div>
204
+ <div class="tab" onclick="switchTab('scores')">Scores</div>
205
+ <div class="tab" onclick="switchTab('log')">Log</div>
206
+ <div class="tab" onclick="switchTab('legend')">Key</div>
207
+ </div>
208
+ <div id="tab-path" class="tab-content active">
209
+ <div id="path-display">
210
+ <p style="font-size:13px;color:var(--text2)">Path will appear here as A* explores.</p>
211
+ </div>
212
+ </div>
213
+ <div id="tab-scores" class="tab-content">
214
+ <div id="scores-list"></div>
215
+ </div>
216
+ <div id="tab-log" class="tab-content">
217
+ <div id="log"></div>
218
+ </div>
219
+ <div id="tab-legend" class="tab-content">
220
+ <div style="display:flex;flex-direction:column;gap:12px;margin-top:4px">
221
+ <div class="legend-row">
222
+ <div class="legend-dot" style="background:var(--amber)"></div>Current centre (being expanded)
223
+ </div>
224
+ <div class="legend-row">
225
+ <div class="legend-dot" style="background:var(--accent)"></div>Open — in A* queue
226
+ </div>
227
+ <div class="legend-row">
228
+ <div class="legend-dot" style="background:#374151;border:1px solid #555"></div>Closed — already explored
229
+ </div>
230
+ <div class="legend-row">
231
+ <div class="legend-dot" style="background:var(--green)"></div>Target article
232
+ </div>
233
+ <div class="legend-row">
234
+ <div class="legend-dot" style="background:var(--node-path)"></div>On current best path
235
+ </div>
236
+ <hr style="border-color:var(--border)" />
237
+ <div style="font-size:13px;color:var(--text2);line-height:1.8">
238
+ <b style="color:var(--text)">f(n)</b> = total estimated cost<br>
239
+ <b style="color:var(--text)">g(n)</b> = hops from start<br>
240
+ <b style="color:var(--text)">h(n)</b> = Wikipedia relevance heuristic<br>
241
+ <br>A* always expands the node with <b style="color:var(--accent2)">lowest f(n)</b> next.
242
+ </div>
243
+ </div>
244
+ </div>
245
+ </div>
246
+ </div>
247
+
248
+ <!-- Path banner -->
249
+ <div id="path-banner"></div>
250
+
251
+ <!-- Centered Success Modal -->
252
+ <div id="success-modal" class="modal-backdrop" onclick="closeSuccessModal(event)">
253
+ <div class="modal-card">
254
+ <button class="modal-corner-close" onclick="closeSuccessModal()" aria-label="Close">
255
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/></svg>
256
+ </button>
257
+ <svg class="modal-success-icon" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2"
258
+ stroke-linecap="round" stroke-linejoin="round">
259
+ <circle cx="6" cy="26" r="3" />
260
+ <circle cx="26" cy="6" r="3" />
261
+ <path d="M8.4 24 C8.4 16 12 16 16 16 C20 16 23.6 16 23.6 8" />
262
+ </svg>
263
+ <h2 class="modal-title">Path Discovered!</h2>
264
+ <div class="modal-stat-row">
265
+ <div class="modal-stat">
266
+ <span class="modal-stat-val" id="modal-hops">—</span>
267
+ <span class="modal-stat-lbl">Hops</span>
268
+ </div>
269
+ <div class="modal-stat">
270
+ <span class="modal-stat-val" id="modal-steps">—</span>
271
+ <span class="modal-stat-lbl">Steps</span>
272
+ </div>
273
+ <div class="modal-stat">
274
+ <span class="modal-stat-val" id="modal-elapsed">—</span>
275
+ <span class="modal-stat-lbl">Time</span>
276
+ </div>
277
+ </div>
278
+ <div id="modal-path-container" class="modal-path"></div>
279
+ <p class="modal-disclaimer">
280
+ Every link above is real, but the wording shown on a page isn't always
281
+ the link's title — e.g. a page may display "iterative" while the link
282
+ actually points to "Iteration", or show "AI" linking to
283
+ "Artificial intelligence". Wikipedia links can be piped like that, so
284
+ the visible text and the article it leads to can differ.
285
+ </p>
286
+ <div style="display:flex; justify-content:center; gap:12px; flex-wrap:wrap;">
287
+ <button class="modal-close-btn" onclick="closeSuccessModal()">Explore Graph</button>
288
+ <button class="modal-copy-btn" id="modal-copy-btn" onclick="copyPath()">Copy Path</button>
289
+ <button class="modal-close-btn"
290
+ style="background:transparent; border:1px solid var(--border); color:var(--text2);"
291
+ onclick="resetAll(true)">New Search</button>
292
+ </div>
293
+ </div>
294
+ </div>
295
+
296
+ <!-- About Modal -->
297
+ <div id="about-modal" class="modal-backdrop" onclick="closeAboutModal(event)">
298
+ <div class="modal-card about-card">
299
+
300
+ <!-- Header -->
301
+ <div style="display:flex;align-items:center;gap:16px;margin-bottom:28px;">
302
+ <svg class="aurelius-logo" viewBox="0 0 100 100" aria-hidden="true"
303
+ style="height:42px;width:42px;opacity:0.92;flex-shrink:0;overflow:visible;filter:drop-shadow(0 0 14px rgba(124,106,247,0.45));">
304
+ <g class="alogo-edges" opacity="0.5">
305
+ <line x1="50" y1="14" x2="79" y2="26" />
306
+ <line x1="79" y1="26" x2="86" y2="54" />
307
+ <line x1="86" y1="54" x2="71" y2="79" />
308
+ <line x1="71" y1="79" x2="50" y2="88" />
309
+ <line x1="50" y1="88" x2="29" y2="79" />
310
+ <line x1="29" y1="79" x2="14" y2="54" />
311
+ <line x1="14" y1="54" x2="21" y2="26" />
312
+ <line x1="21" y1="26" x2="50" y2="14" />
313
+ <line x1="50" y1="14" x2="50" y2="50" />
314
+ <line x1="79" y1="26" x2="50" y2="50" />
315
+ <line x1="21" y1="26" x2="50" y2="50" />
316
+ <line x1="86" y1="54" x2="50" y2="50" />
317
+ <line x1="29" y1="79" x2="50" y2="50" />
318
+ </g>
319
+ <polyline class="alogo-trace" points="21,26 50,50 79,26 86,54 71,79" />
320
+ <g class="alogo-nodes">
321
+ <circle cx="50" cy="14" r="5" />
322
+ <circle cx="79" cy="26" r="5" />
323
+ <circle cx="86" cy="54" r="5" />
324
+ <circle cx="71" cy="79" r="5" />
325
+ <circle cx="50" cy="88" r="5" />
326
+ <circle cx="29" cy="79" r="5" />
327
+ <circle cx="14" cy="54" r="5" />
328
+ <circle cx="21" cy="26" r="5" />
329
+ </g>
330
+ <circle class="alogo-hub" cx="50" cy="50" r="7" />
331
+ </svg>
332
+ <div>
333
+ <h2
334
+ style="font-family:'Cinzel',serif;font-size:22px;font-weight:900;color:#fff;letter-spacing:3px;text-transform:uppercase;line-height:1">
335
+ AURELIUS</h2>
336
+ <p style="font-size:12px;color:var(--text2);margin-top:6px;letter-spacing:0.2px">Wikipedia path explorer</p>
337
+ </div>
338
+ </div>
339
+
340
+ <!-- Quote -->
341
+ <div class="about-quote-block">
342
+ <span class="about-quote-mark">"</span>
343
+ <p class="about-quote-text">The universe is change, our life is what our thoughts make it.</p>
344
+ <p class="about-quote-attr">Marcus Aurelius — Meditations</p>
345
+ </div>
346
+
347
+ <p class="about-prose">Named after the Stoic emperor who wrote that every idea connects to the next. Wikipedia is
348
+ proof of that — millions of articles linked by shared meaning. Aurelius makes those connections visible by
349
+ finding the path between any two articles, however unrelated they seem.</p>
350
+
351
+ <hr class="about-divider" />
352
+ <span class="about-section-label">How it works</span>
353
+ <p style="font-size:11px;color:var(--text2);opacity:0.6;font-style:italic;margin:-8px 0 16px;">
354
+ Works best on desktop — the live graph and side panel are easiest to read on a larger screen.
355
+ </p>
356
+ <div class="about-tech-grid">
357
+ <div class="about-tech-card">
358
+ <svg class="tech-icon" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.8"
359
+ stroke-linecap="round" stroke-linejoin="round">
360
+ <circle cx="6" cy="26" r="3" />
361
+ <circle cx="16" cy="6" r="3" />
362
+ <circle cx="26" cy="20" r="3" />
363
+ <path d="M8.2 24 L14 8.5" />
364
+ <path d="M18 7.8 L24 18.2" />
365
+ <circle cx="16" cy="16" r="1.8" fill="currentColor" stroke="none" />
366
+ </svg>
367
+ <div class="tech-name">A8 Search</div>
368
+ <div class="tech-desc">Built for meaning, not distance. Each candidate's priority blends three signals:
369
+ semantic closeness, a <em>verified</em> backlink bridge to the destination, and category alignment into a
370
+ single score. There is no depth penalty. A8 commits fully to whichever lead looks strongest at that moment
371
+ instead of spreading thin.</div>
372
+ </div>
373
+ <div class="about-tech-card">
374
+ <svg class="tech-icon" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.8"
375
+ stroke-linecap="round" stroke-linejoin="round">
376
+ <circle cx="7" cy="25" r="2.5" />
377
+ <circle cx="16" cy="7" r="2.5" />
378
+ <circle cx="26" cy="22" r="2.5" />
379
+ <circle cx="13" cy="18" r="2.5" />
380
+ <circle cx="22" cy="13" r="2.5" />
381
+ <line x1="9" y1="23.5" x2="11.5" y2="19.5" />
382
+ <line x1="14.8" y1="9.2" x2="13.6" y2="15.5" />
383
+ <line x1="23.5" y1="14.8" x2="24.5" y2="19.5" />
384
+ <line x1="15.4" y1="17.8" x2="20" y2="14.2" />
385
+ </svg>
386
+ <div class="tech-name">Semantic Embeddings</div>
387
+ <div class="tech-desc">Titles become high-dimensional vectors, computed instantly on-device. Cosine similarity
388
+ tells the search how close any candidate is to the target, not through keywords, but through
389
+ <em>meaning</em>.
390
+ </div>
391
+ </div>
392
+ <div class="about-tech-card">
393
+ <svg class="tech-icon" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.8"
394
+ stroke-linecap="round" stroke-linejoin="round">
395
+ <circle cx="27" cy="16" r="4" />
396
+ <circle cx="16" cy="16" r="2.2" fill="currentColor" stroke="none" />
397
+ <path d="M3 16 H13.8" />
398
+ <path d="M10.5 12.5 L13.8 16 L10.5 19.5" />
399
+ <path d="M23 16 H18.2" />
400
+ </svg>
401
+ <div class="tech-name">Bidirectional Target Frontier</div>
402
+ <div class="tech-desc">Before searching, Aurelius maps every article one and two links away from the target.
403
+ Candidates inside that zone receive a strong score boost. The search aims at a wide net, not a single point.
404
+ </div>
405
+ </div>
406
+ <div class="about-tech-card">
407
+ <svg class="tech-icon" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.8"
408
+ stroke-linecap="round" stroke-linejoin="round">
409
+ <circle cx="16" cy="16" r="11" />
410
+ <path d="M10 16 L14 20 L22 12" />
411
+ <path d="M16 5 V8" />
412
+ <path d="M16 24 V27" />
413
+ <path d="M5 16 H8" />
414
+ <path d="M24 16 H27" />
415
+ </svg>
416
+ <div class="tech-name">Stagnation Escape</div>
417
+ <div class="tech-desc">If the search stalls in a dense off-topic cluster, Aurelius detects the plateau,
418
+ widens the beam, and adds a <em>diversity bonus</em> to candidates far from the cluster centroid.
419
+ No fabricated edges, just smarter pruning to break free.</div>
420
+ </div>
421
+ </div>
422
+
423
+ <hr class="about-divider" />
424
+ <span class="about-section-label">What makes it different</span>
425
+ <div class="about-compare">
426
+ <div class="about-compare-head">Traditional Wiki Solver</div>
427
+ <div class="about-compare-head accent">Aurelius</div>
428
+
429
+ <div class="about-compare-cell">Blind to meaning. "Film" and "Film noir" count the same</div>
430
+ <div class="about-compare-cell accent">Ranks every candidate by semantic similarity</div>
431
+
432
+ <div class="about-compare-cell">Treats every link as equally promising</div>
433
+ <div class="about-compare-cell accent">Boosts verified backlink bridges and goal-zone members</div>
434
+
435
+ <div class="about-compare-cell">Expands breadth-first until both frontiers meet</div>
436
+ <div class="about-compare-cell accent">Commits fully to the strongest lead, with no wasted breadth</div>
437
+
438
+ <div class="about-compare-cell">No model of meaning at all, just raw links</div>
439
+ <div class="about-compare-cell accent">Embeddings + graph search, zero language models</div>
440
+ </div>
441
+
442
+ <hr class="about-divider" />
443
+ <span class="about-section-label">Built by</span>
444
+ <p style="font-size:16px;font-weight:800;color:#fff;margin-bottom:6px">Murtaza Vali</p>
445
+ <p class="about-prose" style="margin-bottom:18px">CS student who got curious about A* pathfinding and ended up
446
+ building something with embeddings, a verified backlink graph, and a live force-directed visualization. This is
447
+ what happens when you keep adding "just one more thing."</p>
448
+ <a href="https://www.linkedin.com/in/murtaza-vali-741835274/" target="_blank" rel="noopener"
449
+ class="about-linkedin-btn">LinkedIn ↗</a>
450
+
451
+ <div style="margin-top:28px;text-align:right;">
452
+ <button class="modal-close-btn" onclick="closeAboutModal()">Close</button>
453
+ </div>
454
+ </div>
455
+ </div>
456
+
457
+ <!-- Onboarding Modal (first-time users) -->
458
+ <div id="onboarding-modal" class="modal-backdrop">
459
+ <div class="modal-card onboarding-card">
460
+ <button class="modal-corner-close" onclick="closeOnboarding()" aria-label="Close">
461
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/></svg>
462
+ </button>
463
+ <h2 class="onboarding-title">Welcome to Aurelius</h2>
464
+ <p class="onboarding-tagline">Navigate the Knowledge Graph</p>
465
+ <div class="onboarding-steps">
466
+ <div class="onboarding-step">
467
+ <div class="onboarding-icon">1</div>
468
+ <div>
469
+ <strong>Pick two Wikipedia articles</strong>
470
+ <p>Type any topic in the From and To fields, or hit Random for a surprise pair.</p>
471
+ </div>
472
+ </div>
473
+ <div class="onboarding-step">
474
+ <div class="onboarding-icon">2</div>
475
+ <div>
476
+ <strong>Watch the search unfold</strong>
477
+ <p>Aurelius explores Wikipedia links in real time. Nodes glow as they're visited.</p>
478
+ </div>
479
+ </div>
480
+ <div class="onboarding-step">
481
+ <div class="onboarding-icon">3</div>
482
+ <div>
483
+ <strong>Discover the path</strong>
484
+ <p>The orange trail is the shortest chain of real Wikipedia links connecting your articles.</p>
485
+ </div>
486
+ </div>
487
+ </div>
488
+ <p class="onboarding-tip">
489
+ Tip: try two completely random, unrelated things — that's usually where the most surprising connections show up.
490
+ </p>
491
+ <button class="modal-close-btn onboarding-go-btn" onclick="closeOnboarding()">Let's Go</button>
492
+ </div>
493
+ </div>
494
+
495
+ <script src="app.js"></script>
496
+ </body>
497
+
498
+ </html>
main.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aurelius — Wikipedia Rabbit-Hole Navigator Backend
3
+ Architecture: embeddings + graph search only. No LLM, no external services.
4
+
5
+ Thin entrypoint per architect plan §1 — everything else lives in
6
+ config.py, embedding.py, wiki.py, search.py, server.py. This file keeps
7
+ only the Windows stdout encoding fix (must run before any import that
8
+ might log non-ASCII, e.g. the arrow characters in search.py's log lines)
9
+ and the uvicorn launch.
10
+ """
11
+
12
+ import sys
13
+
14
+ # Windows redirects stdout to cp1252 when it's not an interactive console
15
+ # (e.g. piped to a log file), which raises on the arrow/emoji characters
16
+ # used in log lines below and would otherwise crash mid-search.
17
+ try:
18
+ sys.stdout.reconfigure(encoding="utf-8")
19
+ sys.stderr.reconfigure(encoding="utf-8")
20
+ except Exception:
21
+ pass
22
+
23
+ import os # noqa: E402
24
+
25
+ from server import app # noqa: E402 (must follow the stdout fix above)
26
+
27
+ if __name__ == "__main__":
28
+ import uvicorn
29
+ # Render (and most PaaS hosts) inject a dynamic $PORT and require the
30
+ # app to bind to it; 8000 is just the local-dev fallback.
31
+ port = int(os.getenv("PORT", "8000"))
32
+ print(f"\n🌐 Aurelius backend → http://localhost:{port}")
33
+ print(" Open index.html in your browser\n")
34
+ uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
mainpybackup.txt ADDED
@@ -0,0 +1,799 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aurelius — Wikipedia Rabbit-Hole Navigator Backend
3
+ Architecture: embeddings + graph search only. No LLM, no external services.
4
+
5
+ Per-expansion pipeline:
6
+ 1. Fetch ALL links from article (paginated, up to MAX_FETCH)
7
+ 2. Batch-embed ALL uncached links in one in-process sentence-transformers call
8
+ 3. Score each candidate: cosine-to-target + frontier bonus + category bonus
9
+ 4. Prune to top PRUNE_TOP_K, dropping anything below the raw-cosine floor
10
+ 5. Push survivors to heap (greedy best-first: heap key = h only)
11
+
12
+ The only external dependency at runtime is the Wikipedia API itself. The
13
+ embedding model (sentence-transformers, all-MiniLM-L6-v2) is loaded once,
14
+ in-process, at startup — no network calls, no LLM, no failure mode where
15
+ the search degrades silently.
16
+ """
17
+
18
+ import asyncio
19
+ import heapq
20
+ import json
21
+ import math
22
+ import random
23
+ import re
24
+ import sys
25
+ import time
26
+ from collections import defaultdict
27
+ from pathlib import Path
28
+ from typing import Optional
29
+ from urllib.parse import unquote
30
+
31
+ import httpx
32
+ import numpy as np
33
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
34
+ from fastapi.middleware.cors import CORSMiddleware
35
+ from sentence_transformers import SentenceTransformer
36
+
37
+ # Windows redirects stdout to cp1252 when it's not an interactive console
38
+ # (e.g. piped to a log file), which raises on the arrow/emoji characters
39
+ # used in log lines below and would otherwise crash mid-search.
40
+ try:
41
+ sys.stdout.reconfigure(encoding="utf-8")
42
+ sys.stderr.reconfigure(encoding="utf-8")
43
+ except Exception:
44
+ pass
45
+
46
+ app = FastAPI()
47
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
48
+
49
+ # ── Embedding model: loaded once at startup, in-process ─────────────────────
50
+ EMBED_MODEL_NAME = "all-MiniLM-L6-v2"
51
+ _EMBED_MODEL: Optional[SentenceTransformer] = None
52
+
53
+ @app.on_event("startup")
54
+ async def _startup_embed_model():
55
+ global _EMBED_MODEL
56
+ print(f"[Embed] Loading sentence-transformers model '{EMBED_MODEL_NAME}'...")
57
+ _EMBED_MODEL = SentenceTransformer(EMBED_MODEL_NAME)
58
+ print(f"[Embed] Ready (dim={_EMBED_MODEL.get_embedding_dimension()})")
59
+
60
+ WIKI_API = "https://en.wikipedia.org/w/api.php"
61
+
62
+ MAX_FETCH = 500 # max links fetched from Wikipedia (paginated)
63
+ MAX_PAGES = 6 # safety cap on pagination pages when hunting for a specific goal link
64
+ MAX_HOPS = 60 # search iteration limit
65
+ TOP_DISPLAY = 25 # max neighbours sent to frontend per step
66
+ FRONTIER_DEPTH_2_K = 16 # how many depth-1 target neighbours to expand for depth-2 frontier
67
+
68
+ EMBED_BATCH_SIZE = 128 # in-process model batch size (no HTTP token limits to worry about)
69
+
70
+ PRUNE_TOP_K = 15 # keep this many candidates per expansion after scoring
71
+ PRUNE_FLOOR = 0.15 # hard floor on raw cosine-to-target; frontier members exempt
72
+
73
+ FRONTIER_D1_BONUS = 0.30 # candidate is a direct link of the target
74
+ FRONTIER_D2_BONUS = 0.10 # candidate is 2 hops from the target
75
+ CAT_BONUS_WEIGHT = 0.10 # max bonus for category-embedding alignment
76
+
77
+ _THIS_DIR = Path(__file__).resolve().parent
78
+ SEED_FILE = _THIS_DIR / "common_wiki_searches.txt"
79
+
80
+ WIKI_HEADERS = {
81
+ "User-Agent": "Aurelius-WikiNavigator/6.0 (educational; contact@example.com)",
82
+ "Accept": "application/json",
83
+ }
84
+
85
+ def make_wiki_client() -> httpx.AsyncClient:
86
+ return httpx.AsyncClient(headers=WIKI_HEADERS, follow_redirects=True,
87
+ timeout=httpx.Timeout(25.0))
88
+
89
+
90
+ # ══════════════════════════════════════════════════════════════
91
+ # SEARCH STATS
92
+ # ══════════════════════════════════════════════════════════════
93
+
94
+ class SearchStats:
95
+ def __init__(self):
96
+ self.t0 = time.time()
97
+ self.nodes_visited = 0
98
+ self.nodes_pruned = 0
99
+ self.embed_calls = 0
100
+
101
+ def elapsed(self) -> float:
102
+ return round(time.time() - self.t0, 1)
103
+
104
+ def to_dict(self) -> dict:
105
+ return {
106
+ "nodes_visited": self.nodes_visited,
107
+ "nodes_pruned": self.nodes_pruned,
108
+ "embed_calls": self.embed_calls,
109
+ "elapsed_s": self.elapsed(),
110
+ }
111
+
112
+
113
+ # ══════════════════════════════════════════════════════════════
114
+ # SESSION CACHES (reset each search)
115
+ # ══════════════════════════════════════════════════════════════
116
+
117
+ _emb_cache: dict[str, np.ndarray] = {} # title → embedding vector
118
+ _dead_ends: set[str] = set() # articles with 0 links
119
+
120
+ def reset_session_caches():
121
+ global _emb_cache, _dead_ends
122
+ _emb_cache = {}
123
+ _dead_ends = set()
124
+
125
+
126
+ # ══════════════════════════════════════════════════════════════
127
+ # VECTOR MATH
128
+ # ══════════════════════════════════════════════════════════════
129
+
130
+ def cosine_similarity(a, b) -> float:
131
+ if a is None or b is None:
132
+ return 0.0
133
+ a = np.asarray(a, dtype=np.float32)
134
+ b = np.asarray(b, dtype=np.float32)
135
+ if a.size == 0 or b.size == 0:
136
+ return 0.0
137
+ na = np.linalg.norm(a)
138
+ nb = np.linalg.norm(b)
139
+ if na == 0 or nb == 0:
140
+ return 0.0
141
+ return float(np.dot(a, b) / (na * nb))
142
+
143
+
144
+ # ══════════════════════════════════════════════════════════════
145
+ # EMBEDDING (in-process sentence-transformers, no network calls)
146
+ # ══════════════════════════════════════════════════════════════
147
+
148
+ async def batch_embed(texts: list[str], stats: SearchStats) -> list[np.ndarray]:
149
+ """
150
+ Embed texts via the in-process sentence-transformers model.
151
+ Results are cached per title across the session. Runs the (CPU-bound,
152
+ synchronous) encode call in a thread executor so it never blocks the
153
+ event loop — keeps pause/resume and other WS traffic responsive.
154
+ """
155
+ if not texts or _EMBED_MODEL is None:
156
+ return [np.array([]) for _ in texts]
157
+
158
+ uncached_idx = [i for i, t in enumerate(texts) if t not in _emb_cache]
159
+ uncached_text = [texts[i] for i in uncached_idx]
160
+ if uncached_text:
161
+ loop = asyncio.get_event_loop()
162
+ embs = await loop.run_in_executor(
163
+ None,
164
+ lambda: _EMBED_MODEL.encode(
165
+ uncached_text, convert_to_numpy=True,
166
+ batch_size=EMBED_BATCH_SIZE, show_progress_bar=False,
167
+ ),
168
+ )
169
+ stats.embed_calls += 1
170
+ for orig_i, emb in zip(uncached_idx, embs):
171
+ _emb_cache[texts[orig_i]] = emb
172
+
173
+ return [_emb_cache.get(t, np.array([])) for t in texts]
174
+
175
+
176
+ async def wiki_get(client: httpx.AsyncClient, params: dict, retries: int = 3) -> dict:
177
+ for attempt in range(retries + 1):
178
+ try:
179
+ r = await client.get(WIKI_API, params=params)
180
+ if r.status_code == 429:
181
+ await asyncio.sleep(min(2 ** attempt, 8)); continue
182
+ if r.status_code != 200:
183
+ return {}
184
+ return r.json()
185
+ except httpx.TimeoutException:
186
+ if attempt < retries: await asyncio.sleep(1)
187
+ except Exception as e:
188
+ print(f"[wiki] {e}"); return {}
189
+ return {}
190
+
191
+ def extract_title_from_url(query: str) -> Optional[str]:
192
+ """If query is a Wikipedia URL, extract and decode the article title; else None."""
193
+ m = re.search(r'wikipedia\.org/wiki/([^#?\s]+)', query.strip())
194
+ if not m:
195
+ return None
196
+ return unquote(m.group(1)).replace('_', ' ').strip()
197
+
198
+ async def resolve_article_title(client: httpx.AsyncClient, query: str) -> Optional[str]:
199
+ query = query.strip()
200
+ url_t = extract_title_from_url(query)
201
+ if url_t: query = url_t
202
+
203
+ data = await wiki_get(client, {"action":"query","titles":query,
204
+ "redirects":1,"format":"json","utf8":1})
205
+ for pid, page in data.get("query",{}).get("pages",{}).items():
206
+ if pid != "-1":
207
+ print(f"[resolve] Exact ✓ '{page['title']}'")
208
+ return page["title"]
209
+
210
+ data = await wiki_get(client, {"action":"opensearch","search":query,
211
+ "limit":10,"namespace":0,"format":"json","utf8":1})
212
+ titles = data[1] if isinstance(data, list) and len(data) > 1 else []
213
+ if titles:
214
+ for t in titles:
215
+ if t.lower() == query.lower(): return t
216
+ return titles[0]
217
+
218
+ data = await wiki_get(client, {"action":"query","list":"search","srsearch":query,
219
+ "srlimit":5,"srprop":"title","format":"json","utf8":1})
220
+ results = data.get("query",{}).get("search",[])
221
+ if results:
222
+ return results[0]["title"]
223
+ return None
224
+
225
+ _DATE_RE = re.compile(
226
+ r'^\d{1,2} \w+$|^\w+ \d{1,2}$|^\d{4}$|^\d{4} in '
227
+ r'|^\d{1,2}\w* century'
228
+ r'|^(January|February|March|April|May|June|July|August|'
229
+ r'September|October|November|December)$'
230
+ r'|^\d{4}[-–]\d{2,4}$', re.IGNORECASE
231
+ )
232
+ def _is_date(t: str) -> bool:
233
+ return bool(_DATE_RE.search(t))
234
+
235
+ _JUNK_TITLES = frozenset({
236
+ "Wayback Machine", "OCLC", "Digital object identifier", "CrossRef",
237
+ "JSTOR", "PubMed", "PubMed Central", "Semantic Scholar", "ArXiv",
238
+ "Bibcode", "CiteSeerX", "S2CID", "Zbl", "MR", "PMC",
239
+ })
240
+ def _is_junk(t: str) -> bool:
241
+ """Bibliographic citation artifacts: dead ends with 1-3 links each."""
242
+ return t.endswith("(identifier)") or t in _JUNK_TITLES
243
+
244
+ async def get_all_links(client: httpx.AsyncClient, title: str,
245
+ end_title: str = "",
246
+ target_neighbours: set | None = None) -> list[str]:
247
+ """
248
+ Fetch ALL links via pagination (up to MAX_FETCH).
249
+ Goal link is always included first if found.
250
+ Target neighbours always get priority slots.
251
+ Date/year stubs filtered unless list would be empty.
252
+
253
+ BUGFIX: when hunting for end_title, the old exit condition
254
+ (len(all_links) >= MAX_FETCH) stopped pagination after roughly the
255
+ first alphabetical page of a large article, so a goal link sorting
256
+ late in the alphabet (e.g. "Pakistan" on India's 1000+-link page)
257
+ was never found even though it's a direct 1-hop link. We now keep
258
+ paginating past MAX_FETCH while specifically searching for a goal,
259
+ bounded by MAX_PAGES as a safety cap. Calls with no end_title (e.g.
260
+ pre-seeding target neighbours) are unaffected — same stop condition
261
+ as before.
262
+ """
263
+ all_links: list[str] = []
264
+ date_links: list[str] = []
265
+ goal_link: str | None = None
266
+ end_lower = end_title.lower() if end_title else ""
267
+ params = {
268
+ "action": "query", "titles": title, "prop": "links",
269
+ "pllimit": 500, "plnamespace": 0, "format": "json", "utf8": 1,
270
+ }
271
+ pages_fetched = 0
272
+ while True:
273
+ data = await wiki_get(client, params)
274
+ pages_fetched += 1
275
+ for page in data.get("query", {}).get("pages", {}).values():
276
+ for link in page.get("links", []):
277
+ t = link["title"]
278
+ if end_lower and t.lower() == end_lower:
279
+ goal_link = t; continue
280
+ if _is_junk(t):
281
+ continue
282
+ if _is_date(t):
283
+ date_links.append(t)
284
+ else:
285
+ all_links.append(t)
286
+ cont = data.get("continue", {})
287
+ if "plcontinue" not in cont:
288
+ break
289
+ if goal_link:
290
+ break # found it — no need to keep paginating
291
+ if end_lower:
292
+ # Actively hunting a specific goal: keep going past MAX_FETCH,
293
+ # bounded only by MAX_PAGES (safety cap on API calls).
294
+ if pages_fetched >= MAX_PAGES:
295
+ break
296
+ else:
297
+ # No specific goal (e.g. pre-seeding neighbours): original behavior.
298
+ if len(all_links) + len(date_links) >= MAX_FETCH:
299
+ break
300
+ params["plcontinue"] = cont["plcontinue"]
301
+
302
+ result: list[str] = []
303
+ if goal_link:
304
+ result.append(goal_link)
305
+
306
+ tn = target_neighbours or set()
307
+ bridges = [l for l in all_links if l in tn]
308
+ rest = [l for l in all_links if l not in tn]
309
+ result += bridges
310
+ result += rest
311
+ if len(result) < 5:
312
+ result += date_links[:max(0, 5 - len(result))]
313
+
314
+ return result[:MAX_FETCH]
315
+
316
+ async def get_backlinks(client: httpx.AsyncClient, title: str,
317
+ limit: int = MAX_FETCH) -> list[str]:
318
+ """
319
+ Fetch articles that link TO `title` (Wikipedia prop=linkshere) — the
320
+ correct direction for a forward-search bridge set.
321
+
322
+ get_all_links() returns what `title` points to. That is NOT useful as
323
+ a "one hop from target" bridge set, because Wikipedia links are not
324
+ reciprocal: target -> A does not imply A -> target. A backlink (B ->
325
+ target) is a guaranteed bridge — if the forward search ever reaches B,
326
+ expanding it is certain to find `target` in B's own outbound links.
327
+ Using outbound links here was sending the search on real-but-useless
328
+ detours (e.g. the "Star Wars" article links to "Star Destroyer", which
329
+ in turn links to real-world "Capital ship"/"Destroyer" articles for
330
+ etymology — those score high on the old outbound-based frontier but
331
+ don't link back to anything Star Wars-related themselves).
332
+ """
333
+ all_links: list[str] = []
334
+ date_links: list[str] = []
335
+ params = {
336
+ "action": "query", "titles": title, "prop": "linkshere",
337
+ "lhlimit": 500, "lhnamespace": 0, "format": "json", "utf8": 1,
338
+ }
339
+ pages_fetched = 0
340
+ while True:
341
+ data = await wiki_get(client, params)
342
+ pages_fetched += 1
343
+ for page in data.get("query", {}).get("pages", {}).values():
344
+ for link in page.get("linkshere", []):
345
+ t = link["title"]
346
+ if _is_junk(t):
347
+ continue
348
+ if _is_date(t):
349
+ date_links.append(t)
350
+ else:
351
+ all_links.append(t)
352
+ cont = data.get("continue", {})
353
+ if "lhcontinue" not in cont:
354
+ break
355
+ if len(all_links) + len(date_links) >= limit or pages_fetched >= MAX_PAGES:
356
+ break
357
+ params["lhcontinue"] = cont["lhcontinue"]
358
+ return (all_links + date_links)[:limit]
359
+
360
+ async def get_article_summary(client: httpx.AsyncClient, title: str) -> str:
361
+ data = await wiki_get(client, {"action":"query","titles":title,"prop":"extracts",
362
+ "exintro":True,"explaintext":True,
363
+ "exsentences":2,"format":"json","utf8":1})
364
+ for page in data.get("query",{}).get("pages",{}).values():
365
+ return page.get("extract","")[:220]
366
+ return ""
367
+
368
+ async def get_target_categories(client: httpx.AsyncClient, title: str) -> list[str]:
369
+ """Fetch human-readable categories for the target article."""
370
+ data = await wiki_get(client, {
371
+ "action": "query", "titles": title, "prop": "categories",
372
+ "cllimit": 20, "clshow": "!hidden", "format": "json", "utf8": 1,
373
+ })
374
+ cats = []
375
+ for page in data.get("query", {}).get("pages", {}).values():
376
+ for c in page.get("categories", []):
377
+ # Strip "Category:" prefix
378
+ name = re.sub(r"^Category:", "", c["title"]).strip()
379
+ cats.append(name)
380
+ return cats
381
+
382
+
383
+ def _load_seed_titles() -> list[str]:
384
+ """Read the curated topic list used by the Random button, one title per line."""
385
+ if not SEED_FILE.exists():
386
+ return []
387
+ with open(SEED_FILE, "r", encoding="utf-8") as f:
388
+ return [line.strip() for line in f if line.strip()]
389
+
390
+
391
+ # ══════════════════════════════════════════════════════════════
392
+ # SEARCH ENGINE — greedy best-first, embeddings + graph only
393
+ # ══════════════════════════════════════════════════════════════
394
+
395
+ class AStarNavigator:
396
+ def __init__(self, start: str, end: str, ws: WebSocket):
397
+ self.start = start
398
+ self.end = end
399
+ self.ws = ws
400
+ self.stats = SearchStats()
401
+ self._paused = False
402
+
403
+ self.g_score: dict[str, float] = defaultdict(lambda: math.inf)
404
+ self.came_from: dict[str, Optional[str]] = {}
405
+ self.h_cache: dict[str, float] = {}
406
+ self.open_heap: list[tuple] = []
407
+ self.open_set: set[str] = set()
408
+ self.closed_set:set[str] = set()
409
+
410
+ self.cats_target: list[str] = []
411
+ self.cat_embeddings: list[np.ndarray] = []
412
+ self._target_neighbours: set[str] = set()
413
+ self._frontier: dict[str, int] = {} # title → distance from target (1 or 2)
414
+ self.target_embedding: Optional[np.ndarray] = None
415
+ self._target_cosine: dict[str, float] = {} # raw cosine-to-target, used by prune floor
416
+
417
+
418
+ async def send(self, event: str, data: dict):
419
+ try:
420
+ await self.ws.send_text(json.dumps({"event": event, **data}))
421
+ except Exception:
422
+ pass
423
+
424
+ def reconstruct_path(self, current: str) -> list[str]:
425
+ path = [current]
426
+ while current in self.came_from and self.came_from[current] is not None:
427
+ current = self.came_from[current]
428
+ path.append(current)
429
+ return list(reversed(path))
430
+
431
+ # ── CORE SCORING PIPELINE ─────────────────────────────────────────────────
432
+ async def _rank_candidates(self, candidates: list[str]) -> dict[str, float]:
433
+ """
434
+ score(candidate) =
435
+ cosine(candidate_emb, target_emb) # primary signal
436
+ + 0.30 if candidate backlinks the target directly else 0
437
+ + 0.10 if candidate is one hop further out (depth-2) else 0
438
+ + 0.10 * max(cosine(candidate_emb, category_emb_i))
439
+
440
+ Raw target cosine is stashed in self._target_cosine for the prune
441
+ floor — bonuses should not be able to rescue a semantically
442
+ unrelated candidate, only break ties among related ones.
443
+ """
444
+ if not candidates:
445
+ return {}
446
+
447
+ need_embed = [t for t in candidates if t not in _emb_cache]
448
+ if need_embed:
449
+ await batch_embed(need_embed, self.stats)
450
+
451
+ combined: dict[str, float] = {}
452
+ for t in candidates:
453
+ emb = _emb_cache.get(t)
454
+ s_target = cosine_similarity(emb, self.target_embedding)
455
+ self._target_cosine[t] = s_target
456
+
457
+ fd = self._frontier.get(t)
458
+ frontier_bonus = FRONTIER_D1_BONUS if fd == 1 else FRONTIER_D2_BONUS if fd == 2 else 0.0
459
+
460
+ cat_bonus = 0.0
461
+ if self.cat_embeddings and emb is not None and emb.size:
462
+ cat_sim = max(cosine_similarity(emb, ce) for ce in self.cat_embeddings)
463
+ cat_bonus = cat_sim * CAT_BONUS_WEIGHT
464
+
465
+ combined[t] = min(0.99, s_target + frontier_bonus + cat_bonus)
466
+
467
+ return combined
468
+
469
+ async def _prune(self, pool: list[str],
470
+ scores: dict[str, float]) -> tuple[list[str], int]:
471
+ """
472
+ Sort by combined score, keep the top PRUNE_TOP_K, then drop any of
473
+ those below the raw-cosine floor (frontier members exempt — being
474
+ one or two links from the target is itself strong evidence).
475
+ """
476
+ if not scores:
477
+ return pool, 0
478
+
479
+ ranked = sorted(pool, key=lambda t: -scores.get(t, 0.0))
480
+ top = ranked[:PRUNE_TOP_K]
481
+
482
+ survivors: list[str] = []
483
+ for t in top:
484
+ raw = self._target_cosine.get(t, 0.0)
485
+ if t in self._frontier or raw >= PRUNE_FLOOR:
486
+ survivors.append(t)
487
+ else:
488
+ self.h_cache[t] = 0.95
489
+
490
+ pruned = len(pool) - len(survivors)
491
+ self.stats.nodes_pruned += pruned
492
+ print(f"[Prune] {pruned}/{len(pool)} pruned, {len(survivors)} kept")
493
+ return survivors, pruned
494
+
495
+ # ── RUN ───────────────────────────────────────────────────────────────────
496
+ async def run(self):
497
+ reset_session_caches()
498
+
499
+ async with make_wiki_client() as client:
500
+
501
+ # ── 1. Resolve titles ─────────────────────────────────────────
502
+ await self.send("status", {"message": f"Resolving '{self.start}'..."})
503
+ start_title = await resolve_article_title(client, self.start)
504
+ if not start_title:
505
+ await self.send("error", {"message": f"Cannot find: '{self.start}'"}); return
506
+
507
+ await self.send("status", {"message": f"Resolving '{self.end}'..."})
508
+ end_title = await resolve_article_title(client, self.end)
509
+ if not end_title:
510
+ await self.send("error", {"message": f"Cannot find: '{self.end}'"}); return
511
+
512
+ await self.send("resolved", {"start": start_title, "end": end_title})
513
+ self.end = end_title
514
+
515
+ # ── 2. Target embedding ──────────────────────────────────────
516
+ await self.send("status", {"message": "Embedding target article..."})
517
+ te = await batch_embed([end_title], self.stats)
518
+ if te and te[0] is not None and te[0].size:
519
+ self.target_embedding = te[0]
520
+ _emb_cache[end_title] = te[0]
521
+ print(f"[Embed] Target dim={te[0].shape[0]}")
522
+
523
+ # ── 3. Target categories + their embeddings ───────────────────
524
+ await self.send("status", {"message": "Fetching target article categories..."})
525
+ self.cats_target = await get_target_categories(client, end_title)
526
+ print(f"[init] Target categories: {self.cats_target[:6]}")
527
+ if self.cats_target:
528
+ cat_embs = await batch_embed(self.cats_target, self.stats)
529
+ self.cat_embeddings = [e for e in cat_embs if e is not None and e.size]
530
+
531
+ # ── 4. Pre-seed: target frontier depth-1 ───────────────────────
532
+ # Backlinks (who links TO the target), not outbound links — see
533
+ # get_backlinks() docstring for why this direction matters.
534
+ await self.send("status", {"message": "Pre-loading target backlinks..."})
535
+ target_nb = await get_backlinks(client, end_title)
536
+ self._target_neighbours = set(target_nb)
537
+ for tn in self._target_neighbours:
538
+ self._frontier[tn] = 1
539
+ print(f"[init] Target has {len(self._target_neighbours)} backlinks")
540
+
541
+ # ── 4b. Bidirectional-style frontier: depth-2 expansion ────────
542
+ # For a sample of depth-1 bridges, fetch THEIR backlinks too:
543
+ # X -> A -> target, where A is a depth-1 bridge. Reaching X
544
+ # guarantees a path through A to the target.
545
+ await self.send("status", {"message": "Expanding target frontier (depth 2)..."})
546
+ d2_sample = [t for t in target_nb if not _is_date(t)][:FRONTIER_DEPTH_2_K]
547
+ d2_results = await asyncio.gather(
548
+ *[get_backlinks(client, nb_title) for nb_title in d2_sample]
549
+ )
550
+ d2_count = 0
551
+ for nb_links in d2_results:
552
+ for t in nb_links:
553
+ if t not in self._frontier and not _is_date(t):
554
+ self._frontier[t] = 2
555
+ d2_count += 1
556
+ print(f"[Frontier] Total: {len(self._frontier)} "
557
+ f"(depth-1: {len(self._target_neighbours)}, depth-2: {d2_count})")
558
+
559
+ # ── 5. Init search ────────────────────────────────────────────
560
+ self.g_score[start_title] = 0
561
+ se = await batch_embed([start_title], self.stats)
562
+ if se and se[0] is not None and se[0].size and self.target_embedding is not None:
563
+ h0 = max(0.01, 1.0 - cosine_similarity(se[0], self.target_embedding))
564
+ else:
565
+ h0 = 0.9
566
+ self.h_cache[start_title] = h0
567
+ heapq.heappush(self.open_heap, (h0, 0, start_title))
568
+ self.open_set.add(start_title)
569
+
570
+ summary = await get_article_summary(client, start_title)
571
+ await self.send("node_add", {
572
+ "id": start_title, "g": 0, "h": round(h0,3), "f": round(h0,3),
573
+ "summary": summary, "state": "open",
574
+ "is_start": True, "is_end": False,
575
+ })
576
+ await self.send("node_add", {
577
+ "id": end_title, "g": None, "h": 0.0, "f": None,
578
+ "summary": "", "state": "target",
579
+ "is_start": False, "is_end": True,
580
+ })
581
+
582
+ step = 0
583
+
584
+ # ── 6. Greedy best-first search loop ─────────────────────────
585
+ while self.open_heap and step < MAX_HOPS * 30:
586
+
587
+ while self._paused:
588
+ await asyncio.sleep(0.3)
589
+
590
+ if not self.open_heap:
591
+ break
592
+ h_curr, g_curr, current = heapq.heappop(self.open_heap)
593
+ f_curr = g_curr + h_curr # for display only
594
+ self.open_set.discard(current)
595
+
596
+ if current in self.closed_set:
597
+ continue
598
+
599
+ step += 1
600
+ self.stats.nodes_visited += 1
601
+
602
+ await self.send("expand_node", {
603
+ "id": current,
604
+ "g": g_curr,
605
+ "h": round(h_curr, 3),
606
+ "f": round(f_curr, 3),
607
+ "path_so_far": self.reconstruct_path(current),
608
+ "step": step,
609
+ "stats": self.stats.to_dict(),
610
+ })
611
+
612
+ # Goal check
613
+ if current.lower() == end_title.lower():
614
+ path = self.reconstruct_path(current)
615
+ await self.send("found", {
616
+ "path": path, "steps": step,
617
+ "total_hops": len(path)-1,
618
+ "stats": self.stats.to_dict(),
619
+ })
620
+ return
621
+
622
+ self.closed_set.add(current)
623
+ await self.send("node_state", {"id": current, "state": "closed"})
624
+
625
+ # ── Fetch ALL links ───────────────────────────────────────
626
+ await self.send("status", {"message": f"Fetching links: '{current}'..."})
627
+ neighbours = await get_all_links(
628
+ client, current,
629
+ end_title=end_title,
630
+ target_neighbours=self._target_neighbours,
631
+ )
632
+ print(f"[Search] step {step}: '{current}' → {len(neighbours)} links")
633
+
634
+ if not neighbours:
635
+ _dead_ends.add(current)
636
+ continue
637
+
638
+ # Immediate win
639
+ if end_title in neighbours:
640
+ self.came_from[end_title] = current
641
+ self.g_score[end_title] = g_curr + 1
642
+ path = self.reconstruct_path(end_title)
643
+ print(f"[Search] Direct hit: '{current}' → '{end_title}'")
644
+ await self.send("found", {
645
+ "path": path, "steps": step,
646
+ "total_hops": len(path)-1,
647
+ "stats": self.stats.to_dict(),
648
+ })
649
+ return
650
+
651
+ # Filter closed + dead-ends
652
+ candidates = [
653
+ nb for nb in neighbours
654
+ if nb not in self.closed_set and nb not in _dead_ends
655
+ ]
656
+
657
+ # ── Embed + score pipeline ────────────────────────────────
658
+ await self.send("status", {"message": f"Ranking links for '{current}'..."})
659
+ scores = await self._rank_candidates(candidates)
660
+
661
+ # ── Prune ─────────────────────────────────────────────────
662
+ pool = list(scores.keys())
663
+ survivors, _ = await self._prune(pool, scores)
664
+
665
+ # ── Push to heap ──────────────────────────────────────────
666
+ new_nodes = []
667
+ for nb in survivors:
668
+ if nb in self.closed_set:
669
+ continue
670
+ sc = scores.get(nb, 0.05)
671
+ h_nb = max(0.01, 1.0 - sc)
672
+ self.h_cache[nb] = h_nb
673
+ tent_g = g_curr + 1
674
+ if tent_g < self.g_score[nb]:
675
+ self.came_from[nb] = current
676
+ self.g_score[nb] = tent_g
677
+ # Greedy best-first: heap priority = h only (not g+h).
678
+ # This commits to the most promising direction without
679
+ # penalising depth — Wikipedia paths are short (3-7
680
+ # hops) and depth is not a useful cost signal here.
681
+ heapq.heappush(self.open_heap, (h_nb, tent_g, nb))
682
+ self.open_set.add(nb)
683
+ is_end = nb.lower() == end_title.lower()
684
+ new_nodes.append({
685
+ "id": nb, "g": tent_g,
686
+ "h": round(h_nb, 3), "f": round(tent_g + h_nb, 3),
687
+ "state": "target" if is_end else "open",
688
+ "is_end": is_end, "parent": current,
689
+ })
690
+
691
+ display_nodes = new_nodes[:TOP_DISPLAY]
692
+ display_edges = [{"from": current, "to": n["id"]} for n in display_nodes]
693
+ if display_nodes:
694
+ await self.send("neighbours", {
695
+ "centre": current,
696
+ "nodes": display_nodes,
697
+ "edges": display_edges,
698
+ })
699
+ await asyncio.sleep(0.01)
700
+
701
+ await self.send("not_found", {
702
+ "message": f"No path found within {MAX_HOPS} hops.",
703
+ "visited": list(self.closed_set),
704
+ "stats": self.stats.to_dict(),
705
+ })
706
+
707
+
708
+ # ══════════════════════════════════════════════════════════════
709
+ # REST endpoints (autocomplete lives entirely on the frontend now —
710
+ # it calls Wikipedia's opensearch API directly)
711
+ # ══════════════════════════════════════════════════════════════
712
+
713
+ @app.get("/api/random")
714
+ async def api_random():
715
+ """
716
+ Powers the "Random" button on the start screen. Picks two distinct
717
+ topics from the curated seed list (common_wiki_searches.txt) — NOT a
718
+ fully random Wikipedia page — so the search always gets a
719
+ well-connected pair of articles to find a path between.
720
+ """
721
+ titles = _load_seed_titles()
722
+ if len(titles) < 2:
723
+ return {"start": "Google", "end": "Mohali"}
724
+ start, end = random.sample(titles, 2)
725
+ return {"start": start, "end": end}
726
+
727
+
728
+ @app.get("/api/health")
729
+ async def api_health():
730
+ """
731
+ Polled by the frontend's loading screen on first load. Since FastAPI
732
+ does not accept connections until the startup handler (which loads the
733
+ embedding model) finishes, a successful response here already implies
734
+ the model is ready — there is nothing further to check.
735
+ """
736
+ return {"status": "ready"}
737
+
738
+
739
+ # ══════════════════════════════════════════════════════════════
740
+ # WebSocket endpoint
741
+ # ══════════════════════════════════════════════════════════════
742
+
743
+ @app.websocket("/ws")
744
+ async def websocket_endpoint(ws: WebSocket):
745
+ await ws.accept()
746
+ navigator: Optional[AStarNavigator] = None
747
+ try:
748
+ raw = await ws.receive_text()
749
+ data = json.loads(raw)
750
+ if "control" in data:
751
+ return
752
+
753
+ start = data.get("start", "").strip()
754
+ end = data.get("end", "").strip()
755
+ print(f"\n{'='*60}\n[WS] '{start}' → '{end}'\n{'='*60}")
756
+ if not start or not end:
757
+ await ws.send_text(json.dumps({"event":"error","message":"Need start and end."}))
758
+ return
759
+
760
+ navigator = AStarNavigator(start, end, ws)
761
+
762
+ async def listen_controls():
763
+ while True:
764
+ try:
765
+ msg = await asyncio.wait_for(ws.receive_text(), timeout=0.5)
766
+ ctrl = json.loads(msg)
767
+ if navigator:
768
+ if ctrl.get("control") == "pause":
769
+ navigator._paused = True; print("[WS] Paused")
770
+ elif ctrl.get("control") == "resume":
771
+ navigator._paused = False; print("[WS] Resumed")
772
+ except asyncio.TimeoutError:
773
+ pass
774
+ except Exception:
775
+ break
776
+
777
+ ctrl_task = asyncio.create_task(listen_controls())
778
+ search_task = asyncio.create_task(navigator.run())
779
+ done, pending = await asyncio.wait(
780
+ [ctrl_task, search_task], return_when=asyncio.FIRST_COMPLETED
781
+ )
782
+ for t in pending:
783
+ t.cancel()
784
+
785
+ except WebSocketDisconnect:
786
+ print("[WS] disconnected")
787
+ except Exception as e:
788
+ print(f"[WS] {e}")
789
+ try:
790
+ await ws.send_text(json.dumps({"event":"error","message":str(e)}))
791
+ except Exception:
792
+ pass
793
+
794
+
795
+ if __name__ == "__main__":
796
+ import uvicorn
797
+ print("\n🌐 Aurelius backend → http://localhost:8000")
798
+ print(" Open index.html in your browser\n")
799
+ uvicorn.run(app, host="0.0.0.0", port=8000, log_level="warning")
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi>=0.110
2
+ uvicorn[standard]>=0.29
3
+ httpx>=0.27
4
+ numpy>=1.26
5
+ sentence-transformers>=3.0
search.py ADDED
@@ -0,0 +1,569 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aurelius — search engine: greedy best-first, embeddings + graph only.
3
+
4
+ Architect plan: Honest Bidirectional Meeting-Check Rewrite. Replaces the
5
+ prior stagnation-detection + frontier-jump escape valve (which fabricated
6
+ a came_from pointer with no real Wikipedia link behind it — see the
7
+ Marcus Aurelius -> A* search algorithm incident, where the jump landed on
8
+ a node that doesn't even link to the target) with an honest mechanism:
9
+
10
+ The target's backlinks (depth-1) are real reverse edges: for every
11
+ F in that set, F -> target exists by construction (Wikipedia's own
12
+ linkshere index). The forward search now treats this set as its actual
13
+ goal zone (~100+ members, not one node) instead of just a score hint.
14
+ The moment the forward search expands a node whose real neighbour set
15
+ intersects this zone, current -> F -> target is a fully real two-edge
16
+ bridge — no teleportation, no invented edges.
17
+
18
+ A permanent regression fence (_real_edges / _validate_path /
19
+ _emit_found) refuses to emit any path containing an edge that wasn't
20
+ observed as a real Wikipedia link, so a future change can't silently
21
+ reintroduce a fabricated path.
22
+
23
+ Removed entirely: stagnation detection, frontier-jump, the depth-2
24
+ frontier (couldn't be stitched into a real path without bridge
25
+ bookkeeping it never had), and the category-embedding bonus (capped at
26
+ +0.10, applied weakly everywhere, did not prevent the failure case that
27
+ motivated the jump in the first place).
28
+
29
+ Semantic Drift fix: a later investigation found a path that LOOKED
30
+ fabricated ('Anu' -> 'Iteration' with no visible link on the rendered
31
+ page) but verified real against the live API — it's a piped wikilink
32
+ [[Iteration|iterative]] in a sentence about deity-name etymology, invisible
33
+ to a manual page read but a genuine edge. The actual problem was that the
34
+ search wandered through several pages (Egyptian/Mesopotamian deities) with
35
+ no real relevance to the target before reaching it. Root cause: candidates
36
+ were embedded as bare titles, and a short ambiguous proper noun carries no
37
+ domain signal for the model to score against. Fixed two ways: (1) every
38
+ candidate is now embedded as "{title}. {wikidata_short_desc}" instead of a
39
+ bare title — see wiki.py's get_all_links() and _rank_candidates() below —
40
+ so "Anu. Mesopotamian sky-god" scores clearly far from the target instead
41
+ of landing in noisy title-only cosine; (2) heap priority is now
42
+ h + DEPTH_TIEBREAK_EPSILON*g instead of pure h (config.py), a deliberate,
43
+ documented reversal of the original no-g-cost design — pure-greedy let one
44
+ noisy candidate drag the search arbitrarily deep into its cluster with no
45
+ incentive to prefer a shallower alternative. The depth penalty is small
46
+ enough (max 0.6 at MAX_HOPS=60) that it only breaks ties/near-ties.
47
+ """
48
+
49
+ import asyncio
50
+ import heapq
51
+ import json
52
+ import math
53
+ import time
54
+ from collections import defaultdict
55
+ from typing import Optional
56
+
57
+ import httpx
58
+ import numpy as np
59
+ from fastapi import WebSocket
60
+
61
+ from config import (
62
+ MAX_HOPS, TOP_DISPLAY,
63
+ PRUNE_TOP_K, PRUNE_FLOOR, PRUNE_MIN_SURVIVORS,
64
+ FRONTIER_D1_BONUS, FRONTIER_D2_BONUS,
65
+ DEPTH_TIEBREAK_EPSILON,
66
+ GOAL_ZONE_EXPAND_INTERVAL, GOAL_ZONE_EXPAND_BATCH,
67
+ GOAL_ZONE_D2_BACKLINK_LIMIT,
68
+ STAGNATION_WINDOW, STAGNATION_DELTA, PRUNE_TOP_K_STAGNANT,
69
+ DIVERSITY_WEIGHT,
70
+ )
71
+ from embedding import batch_embed, cosine_similarity, reset_session_caches
72
+ import embedding as _embedding_mod
73
+ # NOTE: the embedding cache and dead-ends set are accessed as
74
+ # _embedding_mod._emb_cache / _embedding_mod._dead_ends (module-qualified),
75
+ # never imported by name. reset_session_caches() REBINDS both names inside
76
+ # the embedding module to fresh dict/set objects on every search — a
77
+ # `from embedding import _emb_cache` here would capture the OLD object at
78
+ # import time and silently go stale after the first reset.
79
+ from wiki import (
80
+ make_wiki_client, resolve_article_title, get_all_links, get_backlinks,
81
+ get_article_summary, check_disambiguation, get_link_display_text, _is_date,
82
+ )
83
+
84
+
85
+ # ══════════════════════════════════════════════════════════════
86
+ # SEARCH STATS
87
+ # ══════════════════════════════════════════════════════════════
88
+
89
+ class SearchStats:
90
+ def __init__(self):
91
+ self.t0 = time.time()
92
+ self.nodes_visited = 0
93
+ self.nodes_pruned = 0
94
+ self.embed_calls = 0
95
+
96
+ def elapsed(self) -> float:
97
+ return round(time.time() - self.t0, 1)
98
+
99
+ def to_dict(self) -> dict:
100
+ return {
101
+ "nodes_visited": self.nodes_visited,
102
+ "nodes_pruned": self.nodes_pruned,
103
+ "embed_calls": self.embed_calls,
104
+ "elapsed_s": self.elapsed(),
105
+ }
106
+
107
+
108
+ # ═════════════════��════════════════════════════════════════════
109
+ # SEARCH ENGINE — greedy best-first, embeddings + graph only
110
+ # ══════════════════════════════════════════════════════════════
111
+
112
+ class AStarNavigator:
113
+ def __init__(self, start: str, end: str, ws: WebSocket):
114
+ self.start = start
115
+ self.end = end
116
+ self.ws = ws
117
+ self.stats = SearchStats()
118
+ self._paused = False
119
+
120
+ self.g_score: dict[str, float] = defaultdict(lambda: math.inf)
121
+ self.came_from: dict[str, Optional[str]] = {}
122
+ self.h_cache: dict[str, float] = {}
123
+ self.open_heap: list[tuple] = []
124
+ self.open_set: set[str] = set()
125
+ self.closed_set:set[str] = set()
126
+
127
+ # Goal zone: depth-1 and depth-2 backlinks of the target.
128
+ # d1: F -> target is a real edge (Wikipedia's linkshere index).
129
+ # d2: d2_node -> d1_bridge is a real edge, so
130
+ # current -> d2_node -> d1_bridge -> target is a 3-edge path.
131
+ self._goal_zone_d1: set[str] = set()
132
+ self._goal_zone_d2: dict[str, str] = {} # d2_node -> d1_bridge
133
+ self._d1_expanded: set[str] = set()
134
+
135
+ self.target_embedding: Optional[np.ndarray] = None
136
+ self._target_cosine: dict[str, float] = {}
137
+
138
+ self._short_desc: dict[str, str] = {}
139
+
140
+ # Stagnation detection / cluster escape
141
+ self._h_history: list[float] = []
142
+ self._recent_embeddings: list[np.ndarray] = []
143
+
144
+ # Permanent regression fence
145
+ self._real_edges: set[tuple[str, str]] = set()
146
+
147
+
148
+ async def send(self, event: str, data: dict):
149
+ try:
150
+ await self.ws.send_text(json.dumps({"event": event, **data}))
151
+ except Exception:
152
+ pass
153
+
154
+ def reconstruct_path(self, current: str) -> list[str]:
155
+ path = [current]
156
+ while current in self.came_from and self.came_from[current] is not None:
157
+ current = self.came_from[current]
158
+ path.append(current)
159
+ return list(reversed(path))
160
+
161
+ def _link(self, parent: str, child: str, g: float):
162
+ """
163
+ Record a came_from pointer AND the real edge backing it. This is
164
+ the only place came_from should ever be assigned — routing every
165
+ assignment through here is what makes _validate_path() a genuine
166
+ regression fence instead of a no-op.
167
+ """
168
+ self.came_from[child] = parent
169
+ self.g_score[child] = g
170
+ self._real_edges.add((parent, child))
171
+
172
+ def _validate_path(self, path: list[str]) -> bool:
173
+ """
174
+ Regression fence: refuse to treat a path as valid unless every
175
+ consecutive pair was recorded by _link() as a real, observed
176
+ Wikipedia link. Guards against ever again emitting a path like
177
+ the Marcus Aurelius -> A* search algorithm one, where a removed
178
+ feature (frontier-jump) fabricated a came_from pointer with no
179
+ corresponding link.
180
+ """
181
+ return all(
182
+ (path[i], path[i + 1]) in self._real_edges
183
+ for i in range(len(path) - 1)
184
+ )
185
+
186
+ async def _emit_found(self, client: httpx.AsyncClient, end_node: str, step: int):
187
+ path = self.reconstruct_path(end_node)
188
+ if not self._validate_path(path):
189
+ print(f"[Guard] Rejected a path with a fabricated edge: {path}")
190
+ await self.send("not_found", {
191
+ "message": "Internal error: candidate path failed validation.",
192
+ "visited": list(self.closed_set),
193
+ "stats": self.stats.to_dict(),
194
+ })
195
+ return
196
+
197
+ # Best-effort: surface the actual piped display text per hop so a
198
+ # genuine-but-piped edge (e.g. "Western world" -[[Amber Road|routes]]->
199
+ # "Amber Road") doesn't look fabricated to someone Ctrl+F-ing the
200
+ # rendered page for the target's title. None entries (not piped, or
201
+ # not found in raw wikitext) are simply omitted by the frontend.
202
+ display_texts = await asyncio.gather(*(
203
+ get_link_display_text(client, path[i], path[i + 1])
204
+ for i in range(len(path) - 1)
205
+ ))
206
+
207
+ await self.send("found", {
208
+ "path": path, "steps": step,
209
+ "total_hops": len(path) - 1,
210
+ "stats": self.stats.to_dict(),
211
+ "display_texts": display_texts,
212
+ })
213
+
214
+ def _is_goal_zone(self, title: str) -> bool:
215
+ return title in self._goal_zone_d1 or title in self._goal_zone_d2
216
+
217
+ # ── CORE SCORING PIPELINE ─────────────────────────────────────────────────
218
+ async def _rank_candidates(self, candidates: list[str],
219
+ is_stagnant: bool = False) -> dict[str, float]:
220
+ if not candidates:
221
+ return {}
222
+
223
+ need_embed = [t for t in candidates if t not in _embedding_mod._emb_cache]
224
+ if need_embed:
225
+ embed_texts = [
226
+ f"{t}. {self._short_desc[t]}" if t in self._short_desc else t
227
+ for t in need_embed
228
+ ]
229
+ await batch_embed(need_embed, self.stats, embed_texts=embed_texts)
230
+
231
+ centroid = None
232
+ if is_stagnant and self._recent_embeddings:
233
+ centroid = np.mean(self._recent_embeddings, axis=0)
234
+
235
+ combined: dict[str, float] = {}
236
+ for t in candidates:
237
+ emb = _embedding_mod._emb_cache.get(t)
238
+ s_target = cosine_similarity(emb, self.target_embedding)
239
+ self._target_cosine[t] = s_target
240
+
241
+ if t in self._goal_zone_d1:
242
+ frontier_bonus = FRONTIER_D1_BONUS
243
+ elif t in self._goal_zone_d2:
244
+ frontier_bonus = FRONTIER_D2_BONUS
245
+ else:
246
+ frontier_bonus = 0.0
247
+
248
+ diversity_bonus = 0.0
249
+ if is_stagnant and centroid is not None and emb is not None:
250
+ diversity_bonus = DIVERSITY_WEIGHT * (1.0 - cosine_similarity(emb, centroid))
251
+
252
+ combined[t] = min(0.99, s_target + frontier_bonus + diversity_bonus)
253
+
254
+ return combined
255
+
256
+ async def _prune(self, pool: list[str],
257
+ scores: dict[str, float],
258
+ is_stagnant: bool = False) -> tuple[list[str], int]:
259
+ if not scores:
260
+ return pool, 0
261
+
262
+ top_k = PRUNE_TOP_K_STAGNANT if is_stagnant else PRUNE_TOP_K
263
+ ranked = sorted(pool, key=lambda t: -scores.get(t, 0.0))
264
+ top = ranked[:top_k]
265
+
266
+ survivors: list[str] = []
267
+ for i, t in enumerate(top):
268
+ raw = self._target_cosine.get(t, 0.0)
269
+ if i < PRUNE_MIN_SURVIVORS or self._is_goal_zone(t) or raw >= PRUNE_FLOOR:
270
+ survivors.append(t)
271
+ else:
272
+ self.h_cache[t] = 0.95
273
+
274
+ pruned = len(pool) - len(survivors)
275
+ self.stats.nodes_pruned += pruned
276
+ print(f"[Prune] {pruned}/{len(pool)} pruned, {len(survivors)} kept"
277
+ + (" (stagnant beam)" if is_stagnant else ""))
278
+ return survivors, pruned
279
+
280
+ # ── GOAL ZONE EXPANSION ─────────────────────────────────────────────────
281
+ async def _expand_goal_zone(self, client, end_title: str):
282
+ unexpanded = [m for m in self._goal_zone_d1 if m not in self._d1_expanded]
283
+ batch = unexpanded[:GOAL_ZONE_EXPAND_BATCH]
284
+ for d1_member in batch:
285
+ self._d1_expanded.add(d1_member)
286
+ d2_backlinks = await get_backlinks(client, d1_member,
287
+ limit=GOAL_ZONE_D2_BACKLINK_LIMIT)
288
+ for d2 in d2_backlinks:
289
+ if d2 not in self._goal_zone_d2 and d2 not in self._goal_zone_d1:
290
+ self._goal_zone_d2[d2] = d1_member
291
+ if batch:
292
+ print(f"[GoalZone] Expanded: {len(self._goal_zone_d1)} d1 + "
293
+ f"{len(self._goal_zone_d2)} d2 nodes")
294
+
295
+ # ── RUN ───────────────────────────────────────────────────────────────────
296
+ async def run(self):
297
+ reset_session_caches()
298
+
299
+ async with make_wiki_client() as client:
300
+
301
+ # ── 1. Resolve titles ─────────────────────────────────────────
302
+ await self.send("status", {"message": f"Resolving '{self.start}'..."})
303
+ start_title = await resolve_article_title(client, self.start)
304
+ if not start_title:
305
+ await self.send("error", {"message": f"Cannot find: '{self.start}'"}); return
306
+
307
+ await self.send("status", {"message": f"Resolving '{self.end}'..."})
308
+ end_title = await resolve_article_title(client, self.end)
309
+ if not end_title:
310
+ await self.send("error", {"message": f"Cannot find: '{self.end}'"}); return
311
+
312
+ await self.send("resolved", {"start": start_title, "end": end_title})
313
+ self.end = end_title
314
+
315
+ # ── 1b. Disambiguation redirect ──────────────────────────────
316
+ is_disambig = await check_disambiguation(client, end_title)
317
+ if is_disambig:
318
+ await self.send("status", {"message": f"'{end_title}' is a disambiguation page, finding best match..."})
319
+ disambig_links, disambig_descs = await get_all_links(client, end_title)
320
+ if disambig_links:
321
+ self._short_desc.update(disambig_descs)
322
+ embed_texts_d = [
323
+ f"{t}. {disambig_descs[t]}" if t in disambig_descs else t
324
+ for t in disambig_links
325
+ ]
326
+ query_embs = await batch_embed(
327
+ [self.end], self.stats,
328
+ embed_texts=[self.end],
329
+ )
330
+ cand_embs = await batch_embed(
331
+ disambig_links, self.stats,
332
+ embed_texts=embed_texts_d,
333
+ )
334
+ if query_embs and query_embs[0] is not None:
335
+ query_emb = query_embs[0]
336
+ best_title, best_score = None, -1.0
337
+ end_lower = self.end.lower()
338
+ for i, t in enumerate(disambig_links):
339
+ if cand_embs[i] is not None:
340
+ sc = cosine_similarity(cand_embs[i], query_emb)
341
+ if t.lower().startswith(end_lower):
342
+ sc += 0.25
343
+ if sc > best_score:
344
+ best_score, best_title = sc, t
345
+ if best_title:
346
+ print(f"[Disambig] '{end_title}' → '{best_title}' (score={best_score:.3f})")
347
+ end_title = best_title
348
+ self.end = end_title
349
+ await self.send("resolved", {"start": start_title, "end": end_title})
350
+
351
+ # ── 2. Target embedding ──────────────────────────────────────
352
+ # Enriched with the article's lead extract (Semantic Drift fix):
353
+ # the target embedding is the cosine anchor for every score in
354
+ # the search, so its quality matters more than any single
355
+ # candidate's. A bare title gives the model nothing to work
356
+ # with for ambiguous/short titles; the extract gives it the
357
+ # actual subject matter.
358
+ await self.send("status", {"message": "Embedding target article..."})
359
+ target_extract = await get_article_summary(client, end_title)
360
+ te = await batch_embed(
361
+ [end_title], self.stats,
362
+ embed_texts=[f"{end_title}. {target_extract}" if target_extract else end_title],
363
+ )
364
+ if te and te[0] is not None and te[0].size:
365
+ self.target_embedding = te[0]
366
+ _embedding_mod._emb_cache[end_title] = te[0]
367
+ print(f"[Embed] Target dim={te[0].shape[0]}")
368
+
369
+ # ── 3. Pre-seed: target backlinks = the goal zone ──────────────
370
+ await self.send("status", {"message": "Pre-loading target backlinks..."})
371
+ target_nb = await get_backlinks(client, end_title)
372
+ self._goal_zone_d1 = set(target_nb)
373
+ print(f"[init] Target goal zone: {len(self._goal_zone_d1)} d1 backlinks")
374
+
375
+ # ── 4. Init search ────────────────────────────────────────────
376
+ # Same extract-enrichment as the target embedding above — the
377
+ # start node's h0 anchors every depth-tiebreak comparison for
378
+ # the rest of the search.
379
+ self.g_score[start_title] = 0
380
+ summary = await get_article_summary(client, start_title)
381
+ se = await batch_embed(
382
+ [start_title], self.stats,
383
+ embed_texts=[f"{start_title}. {summary}" if summary else start_title],
384
+ )
385
+ if se and se[0] is not None and se[0].size and self.target_embedding is not None:
386
+ h0 = max(0.01, 1.0 - cosine_similarity(se[0], self.target_embedding))
387
+ else:
388
+ h0 = 0.9
389
+ self.h_cache[start_title] = h0
390
+ heapq.heappush(self.open_heap, (h0, h0, 0, start_title))
391
+ self.open_set.add(start_title)
392
+
393
+ await self.send("node_add", {
394
+ "id": start_title, "g": 0, "h": round(h0,3), "f": round(h0,3),
395
+ "summary": summary, "state": "open",
396
+ "is_start": True, "is_end": False,
397
+ })
398
+ await self.send("node_add", {
399
+ "id": end_title, "g": None, "h": 0.0, "f": None,
400
+ "summary": "", "state": "target",
401
+ "is_start": False, "is_end": True,
402
+ })
403
+
404
+ step = 0
405
+
406
+ # ── 5. Greedy best-first search loop ─────────────────────────
407
+ while self.open_heap and step < MAX_HOPS:
408
+
409
+ while self._paused:
410
+ await asyncio.sleep(0.3)
411
+
412
+ if not self.open_heap:
413
+ break
414
+ _priority, h_curr, g_curr, current = heapq.heappop(self.open_heap)
415
+ f_curr = g_curr + h_curr # for display only
416
+ self.open_set.discard(current)
417
+
418
+ if current in self.closed_set:
419
+ continue
420
+
421
+ step += 1
422
+ self.stats.nodes_visited += 1
423
+
424
+ await self.send("expand_node", {
425
+ "id": current,
426
+ "g": g_curr,
427
+ "h": round(h_curr, 3),
428
+ "f": round(f_curr, 3),
429
+ "path_so_far": self.reconstruct_path(current),
430
+ "step": step,
431
+ "stats": self.stats.to_dict(),
432
+ })
433
+
434
+ # Goal check
435
+ if current.lower() == end_title.lower():
436
+ await self._emit_found(client, current, step)
437
+ return
438
+
439
+ self.closed_set.add(current)
440
+ await self.send("node_state", {"id": current, "state": "closed"})
441
+
442
+ # ── Periodic goal-zone expansion ──────────────────────────
443
+ if step > 0 and step % GOAL_ZONE_EXPAND_INTERVAL == 0:
444
+ await self._expand_goal_zone(client, end_title)
445
+
446
+ # ── Fetch ALL links ───────────────────────────────────────
447
+ await self.send("status", {"message": f"Fetching links: '{current}'..."})
448
+ neighbours, short_desc = await get_all_links(
449
+ client, current,
450
+ end_title=end_title,
451
+ target_neighbours=self._goal_zone_d1,
452
+ )
453
+ self._short_desc.update(short_desc)
454
+ print(f"[Search] step {step}: '{current}' → {len(neighbours)} links")
455
+
456
+ if not neighbours:
457
+ _embedding_mod._dead_ends.add(current)
458
+ continue
459
+
460
+ # Immediate win: current links directly to the target.
461
+ if end_title in neighbours:
462
+ self._link(current, end_title, g_curr + 1)
463
+ print(f"[Search] Direct hit: '{current}' → '{end_title}'")
464
+ await self._emit_found(client, end_title, step)
465
+ return
466
+
467
+ # D1 meeting check
468
+ bridge = next(
469
+ (nb for nb in neighbours if nb in self._goal_zone_d1), None
470
+ )
471
+ if bridge:
472
+ self._link(current, bridge, g_curr + 1)
473
+ self._link(bridge, end_title, g_curr + 2)
474
+ print(f"[Search] Meeting d1: '{current}' → '{bridge}' → '{end_title}'")
475
+ await self._emit_found(client, end_title, step)
476
+ return
477
+
478
+ # D2 meeting check
479
+ d2_bridge = next(
480
+ (nb for nb in neighbours if nb in self._goal_zone_d2), None
481
+ )
482
+ if d2_bridge:
483
+ d1_bridge = self._goal_zone_d2[d2_bridge]
484
+ self._link(current, d2_bridge, g_curr + 1)
485
+ self._link(d2_bridge, d1_bridge, g_curr + 2)
486
+ self._link(d1_bridge, end_title, g_curr + 3)
487
+ print(f"[Search] Meeting d2: '{current}' → '{d2_bridge}' → '{d1_bridge}' → '{end_title}'")
488
+ await self._emit_found(client, end_title, step)
489
+ return
490
+
491
+ # Filter closed + dead-ends
492
+ candidates = [
493
+ nb for nb in neighbours
494
+ if nb not in self.closed_set and nb not in _embedding_mod._dead_ends
495
+ ]
496
+
497
+ # ── Stagnation detection ──────────────────────────────────
498
+ is_stagnant = False
499
+ if len(self._h_history) >= 2 * STAGNATION_WINDOW:
500
+ recent_best = min(self._h_history[-STAGNATION_WINDOW:])
501
+ prev_best = min(self._h_history[-2*STAGNATION_WINDOW:-STAGNATION_WINDOW])
502
+ is_stagnant = (prev_best - recent_best) < STAGNATION_DELTA
503
+ if is_stagnant:
504
+ print(f"[Stagnation] Detected at step {step}, widening beam")
505
+
506
+ # ── Embed + score pipeline ────────────────────────────────
507
+ await self.send("status", {"message": f"Ranking links for '{current}'..."})
508
+ scores = await self._rank_candidates(candidates, is_stagnant=is_stagnant)
509
+
510
+ # Track h-history and cluster centroid
511
+ if scores:
512
+ best_h = min(max(0.01, 1.0 - sc) for sc in scores.values())
513
+ self._h_history.append(best_h)
514
+ current_emb = _embedding_mod._emb_cache.get(current)
515
+ if current_emb is not None:
516
+ self._recent_embeddings.append(current_emb)
517
+ if len(self._recent_embeddings) > STAGNATION_WINDOW:
518
+ self._recent_embeddings.pop(0)
519
+
520
+ # ── Prune ─────────────────────────────────────────────────
521
+ pool = list(scores.keys())
522
+ survivors, _ = await self._prune(pool, scores, is_stagnant=is_stagnant)
523
+
524
+ # ── Push to heap ──────────────────────────────────────────
525
+ new_nodes = []
526
+ for nb in survivors:
527
+ if nb in self.closed_set:
528
+ continue
529
+ sc = scores.get(nb, 0.05)
530
+ h_nb = max(0.01, 1.0 - sc)
531
+ self.h_cache[nb] = h_nb
532
+ tent_g = g_curr + 1
533
+ if tent_g < self.g_score[nb]:
534
+ self._link(current, nb, tent_g)
535
+ # Greedy best-first with a small depth tie-breaker
536
+ # (Semantic Drift fix — deliberate reversal of the
537
+ # prior "heap priority = h only" design, see
538
+ # config.py's DEPTH_TIEBREAK_EPSILON and CLAUDE.md):
539
+ # pure-h let one noisy high-scoring proper noun drag
540
+ # the search arbitrarily deep into its cluster, with
541
+ # nothing to prefer a shallower alternative once it
542
+ # committed. h_nb is still kept as the true heuristic
543
+ # for display/h_cache — only heap ordering changes.
544
+ priority = h_nb + DEPTH_TIEBREAK_EPSILON * tent_g
545
+ heapq.heappush(self.open_heap, (priority, h_nb, tent_g, nb))
546
+ self.open_set.add(nb)
547
+ is_end = nb.lower() == end_title.lower()
548
+ new_nodes.append({
549
+ "id": nb, "g": tent_g,
550
+ "h": round(h_nb, 3), "f": round(tent_g + h_nb, 3),
551
+ "state": "target" if is_end else "open",
552
+ "is_end": is_end, "parent": current,
553
+ })
554
+
555
+ display_nodes = new_nodes[:TOP_DISPLAY]
556
+ display_edges = [{"from": current, "to": n["id"]} for n in display_nodes]
557
+ if display_nodes:
558
+ await self.send("neighbours", {
559
+ "centre": current,
560
+ "nodes": display_nodes,
561
+ "edges": display_edges,
562
+ })
563
+ await asyncio.sleep(0.01)
564
+
565
+ await self.send("not_found", {
566
+ "message": f"No path found within {MAX_HOPS} hops.",
567
+ "visited": list(self.closed_set),
568
+ "stats": self.stats.to_dict(),
569
+ })
server.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aurelius — FastAPI app: /api/random, /api/health, /ws handler.
3
+
4
+ Module split per architect plan §1. Owns the FastAPI app instance, CORS
5
+ middleware, the startup handler (now delegating model loading to
6
+ embedding.load_model() per plan §3), and the WebSocket endpoint that drives
7
+ one AStarNavigator session per connection.
8
+ """
9
+
10
+ import asyncio
11
+ import json
12
+ import random
13
+
14
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+
17
+ import embedding
18
+ from search import AStarNavigator
19
+ from wiki import _load_seed_titles
20
+
21
+ app = FastAPI()
22
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
23
+
24
+
25
+ @app.on_event("startup")
26
+ async def _startup_embed_model():
27
+ await embedding.load_model()
28
+
29
+
30
+ # ══════════════════════════════════════════════════════════════
31
+ # REST endpoints (autocomplete lives entirely on the frontend now —
32
+ # it calls Wikipedia's opensearch API directly)
33
+ # ══════════════════════════════════════════════════════════════
34
+
35
+ @app.get("/")
36
+ async def api_root():
37
+ """
38
+ This backend never serves the frontend (no static files here — see
39
+ CLAUDE.md). Visiting this URL directly is a normal thing for a human
40
+ to try, so return a clear message instead of a bare 404 — the actual
41
+ app is index.html, opened directly or served alongside this on its
42
+ own port.
43
+ """
44
+ return {"status": "Aurelius backend is running.", "frontend": "Open index.html directly in your browser."}
45
+
46
+
47
+ @app.get("/api/random")
48
+ async def api_random():
49
+ """
50
+ Powers the "Random" button on the start screen. Picks two distinct
51
+ topics from the curated seed list (common_wiki_searches.txt) — NOT a
52
+ fully random Wikipedia page — so the search always gets a
53
+ well-connected pair of articles to find a path between.
54
+ """
55
+ titles = _load_seed_titles()
56
+ if len(titles) < 2:
57
+ return {"start": "Google", "end": "Mohali"}
58
+ start, end = random.sample(titles, 2)
59
+ return {"start": start, "end": end}
60
+
61
+
62
+ @app.get("/api/health")
63
+ async def api_health():
64
+ """
65
+ Polled by the frontend's loading screen on first load. Since FastAPI
66
+ does not accept connections until the startup handler (which loads the
67
+ embedding model) finishes, a successful response here already implies
68
+ the model is ready — there is nothing further to check.
69
+ """
70
+ return {"status": "ready"}
71
+
72
+
73
+ # ══════════════════════════════════════════════════════════════
74
+ # WebSocket endpoint
75
+ # ══════════════════════════════════════════════════════════════
76
+
77
+ @app.websocket("/ws")
78
+ async def websocket_endpoint(ws: WebSocket):
79
+ await ws.accept()
80
+ navigator: AStarNavigator | None = None
81
+ try:
82
+ raw = await ws.receive_text()
83
+ data = json.loads(raw)
84
+ if "control" in data:
85
+ return
86
+
87
+ start = data.get("start", "").strip()
88
+ end = data.get("end", "").strip()
89
+ print(f"\n{'='*60}\n[WS] '{start}' → '{end}'\n{'='*60}")
90
+ if not start or not end:
91
+ await ws.send_text(json.dumps({"event":"error","message":"Need start and end."}))
92
+ return
93
+
94
+ navigator = AStarNavigator(start, end, ws)
95
+
96
+ async def listen_controls():
97
+ while True:
98
+ try:
99
+ msg = await asyncio.wait_for(ws.receive_text(), timeout=0.5)
100
+ ctrl = json.loads(msg)
101
+ if navigator:
102
+ if ctrl.get("control") == "pause":
103
+ navigator._paused = True; print("[WS] Paused")
104
+ elif ctrl.get("control") == "resume":
105
+ navigator._paused = False; print("[WS] Resumed")
106
+ except asyncio.TimeoutError:
107
+ pass
108
+ except Exception:
109
+ break
110
+
111
+ ctrl_task = asyncio.create_task(listen_controls())
112
+ search_task = asyncio.create_task(navigator.run())
113
+ done, pending = await asyncio.wait(
114
+ [ctrl_task, search_task], return_when=asyncio.FIRST_COMPLETED
115
+ )
116
+ for t in pending:
117
+ t.cancel()
118
+
119
+ except WebSocketDisconnect:
120
+ print("[WS] disconnected")
121
+ except Exception as e:
122
+ print(f"[WS] {e}")
123
+ try:
124
+ await ws.send_text(json.dumps({"event":"error","message":str(e)}))
125
+ except Exception:
126
+ pass
styles.css ADDED
@@ -0,0 +1,2358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ AURELIUS — styles.css
3
+ Redesign: warm editorial / knowledge explorer aesthetic
4
+ Amber-gold palette · Outfit body · DM Mono for data
5
+ ============================================================ */
6
+
7
+ :root {
8
+ /* ─── Warm dark palette ─── */
9
+ --bg: #0d0c08;
10
+ --bg2: #141209;
11
+ --bg3: #1e1b10;
12
+ --border: rgba(255, 243, 210, 0.08);
13
+ --border-bright: rgba(201, 138, 46, 0.45);
14
+ --text: #ede8d4;
15
+ --text2: #7d7562;
16
+ --accent: #c98a2e;
17
+ --accent2: #e4b254;
18
+ --green: #4aab79;
19
+ --amber: #e07d2c;
20
+ --red: #cb4c3a;
21
+ --cyan: #3aa4be;
22
+
23
+ /* ─── Graph tokens ─── */
24
+ --node-open: #c98a2e;
25
+ --node-closed: #2a261a;
26
+ --node-centre: #e07d2c;
27
+ --node-target: #4aab79;
28
+ --node-path: #e56c2a;
29
+ --edge-faint: rgba(201, 138, 46, 0.08);
30
+ --path-edge: rgba(229, 108, 42, 0.9);
31
+
32
+ /* ─── Shadow system ─── */
33
+ --shadow-rgb: 0, 0, 0;
34
+ --shadow-accent-rgb: 201, 138, 46;
35
+ --shadow-sm: 0 1px 4px rgba(0, 0, 0, .32);
36
+ --shadow-md: 0 4px 16px rgba(0, 0, 0, .44);
37
+ --shadow-lg: 0 12px 40px rgba(0, 0, 0, .56);
38
+ --shadow-xl: 0 24px 64px rgba(0, 0, 0, .72);
39
+ --shadow-glow: 0 0 20px rgba(201, 138, 46, .20);
40
+ --shadow-glow-strong: 0 0 32px rgba(201, 138, 46, .36);
41
+ }
42
+
43
+ /* ─── Reset ─── */
44
+ *,
45
+ *::before,
46
+ *::after {
47
+ box-sizing: border-box;
48
+ margin: 0;
49
+ padding: 0;
50
+ font-family: 'Outfit', sans-serif;
51
+ }
52
+
53
+ body {
54
+ background: var(--bg);
55
+ color: var(--text);
56
+ height: 100vh;
57
+ overflow: hidden;
58
+ display: flex;
59
+ flex-direction: column;
60
+ position: relative;
61
+ }
62
+
63
+ body::before {
64
+ content: "";
65
+ position: absolute;
66
+ inset: 0;
67
+ background:
68
+ radial-gradient(ellipse at 25% 0%, rgba(201, 138, 46, .04) 0%, transparent 55%),
69
+ radial-gradient(ellipse at 80% 100%, rgba(74, 171, 121, .03) 0%, transparent 50%);
70
+ pointer-events: none;
71
+ z-index: -1;
72
+ }
73
+
74
+ /* ─── Scrollbars ─── */
75
+ ::-webkit-scrollbar {
76
+ width: 4px;
77
+ }
78
+
79
+ ::-webkit-scrollbar-track {
80
+ background: transparent;
81
+ }
82
+
83
+ ::-webkit-scrollbar-thumb {
84
+ background: rgba(255, 243, 210, .1);
85
+ border-radius: 2px;
86
+ }
87
+
88
+ ::-webkit-scrollbar-thumb:hover {
89
+ background: rgba(255, 243, 210, .18);
90
+ }
91
+
92
+ /* ══════════════════════════════════════════════════════════
93
+ LOADING SCREEN
94
+ ══════════════════════════════════════════════════════════ */
95
+ #loading-screen {
96
+ position: fixed;
97
+ inset: 0;
98
+ z-index: 500;
99
+ background: var(--bg);
100
+ display: flex;
101
+ flex-direction: column;
102
+ align-items: center;
103
+ justify-content: center;
104
+ transition: opacity 0.5s ease;
105
+ }
106
+
107
+ #loading-screen.fade-out {
108
+ opacity: 0;
109
+ pointer-events: none;
110
+ }
111
+
112
+ #loading-screen.hidden {
113
+ display: none;
114
+ }
115
+
116
+ #loading-logo {
117
+ font-family: 'Cinzel', serif;
118
+ font-size: 17px;
119
+ font-weight: 700;
120
+ letter-spacing: 7px;
121
+ text-transform: uppercase;
122
+ color: var(--text);
123
+ opacity: 0.5;
124
+ margin-bottom: 28px;
125
+ }
126
+
127
+ #loading-spinner {
128
+ width: 22px;
129
+ height: 22px;
130
+ border-radius: 50%;
131
+ border: 1.5px solid rgba(255, 243, 210, .1);
132
+ border-top-color: var(--accent2);
133
+ animation: loading-spin 0.85s linear infinite;
134
+ }
135
+
136
+ @keyframes loading-spin {
137
+ to {
138
+ transform: rotate(360deg);
139
+ }
140
+ }
141
+
142
+ /* ══════════════════════════════════════════════════════════
143
+ HERO LANDING SCREEN
144
+ ══════════════════════════════════════════════════════════ */
145
+ #hero {
146
+ position: fixed;
147
+ inset: 0;
148
+ z-index: 200;
149
+ background: var(--bg);
150
+ display: flex;
151
+ flex-direction: column;
152
+ align-items: center;
153
+ justify-content: center;
154
+ gap: 0;
155
+ transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
156
+ }
157
+
158
+ #hero.hiding {
159
+ opacity: 0;
160
+ transform: translateY(-28px) scale(0.99);
161
+ pointer-events: none;
162
+ }
163
+
164
+ #hero.hidden {
165
+ display: none;
166
+ }
167
+
168
+ /* Title — keep animation intact, app.js sets --center-dy and adds .entrance */
169
+ #hero-title {
170
+ font-family: 'Cinzel', serif;
171
+ font-size: 66px;
172
+ font-weight: 900;
173
+ letter-spacing: 10px;
174
+ margin-bottom: 14px;
175
+ text-align: center;
176
+ text-transform: uppercase;
177
+ color: var(--text);
178
+ --center-dy: 38vh;
179
+ opacity: 0;
180
+ transform: scale(1);
181
+ }
182
+
183
+ #hero-title.entrance {
184
+ animation: title-entrance 4.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
185
+ }
186
+
187
+ @keyframes title-entrance {
188
+ 0% {
189
+ opacity: 0;
190
+ transform: scale(1) translateY(0);
191
+ }
192
+
193
+ 14% {
194
+ opacity: 1;
195
+ transform: scale(1) translateY(0);
196
+ }
197
+
198
+ 53% {
199
+ opacity: 1;
200
+ transform: scale(1.45) translateY(var(--center-dy));
201
+ }
202
+
203
+ 88% {
204
+ opacity: 1;
205
+ transform: scale(1.45) translateY(var(--center-dy));
206
+ }
207
+
208
+ 100% {
209
+ opacity: 1;
210
+ transform: scale(1) translateY(0);
211
+ }
212
+ }
213
+
214
+ /* Solid mask — same role as before, warm bg */
215
+ #hero-bg-mask {
216
+ position: absolute;
217
+ inset: 0;
218
+ background: var(--bg);
219
+ opacity: 1;
220
+ pointer-events: none;
221
+ transition: opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1);
222
+ }
223
+
224
+ #hero-bg-mask.hide {
225
+ opacity: 0;
226
+ }
227
+
228
+ /* Reveal group — same semantics */
229
+ .hero-reveal {
230
+ opacity: 0;
231
+ transform: translateY(8px);
232
+ transition: opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1),
233
+ transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
234
+ }
235
+
236
+ .hero-reveal.show {
237
+ opacity: 1;
238
+ transform: translateY(0);
239
+ }
240
+
241
+ /* Hero rest container */
242
+ #hero-rest {
243
+ display: flex;
244
+ flex-direction: column;
245
+ align-items: center;
246
+ width: 100%;
247
+ }
248
+
249
+ /* Tagline */
250
+ #hero-tagline {
251
+ font-size: 10px;
252
+ font-weight: 600;
253
+ letter-spacing: 4.5px;
254
+ text-transform: uppercase;
255
+ color: var(--accent);
256
+ opacity: 0;
257
+ margin-top: 0;
258
+ margin-bottom: 52px;
259
+ text-align: center;
260
+ transition: opacity 0.5s ease;
261
+ }
262
+
263
+ /* ID selector wins over .hero-reveal.show, keeping tagline at 75% opacity */
264
+ #hero-tagline.show {
265
+ opacity: 0.75;
266
+ }
267
+
268
+ #hero-sub {
269
+ font-size: 14px;
270
+ color: var(--text2);
271
+ margin-bottom: 44px;
272
+ text-align: center;
273
+ font-weight: 400;
274
+ letter-spacing: 0.1px;
275
+ max-width: 430px;
276
+ line-height: 1.8;
277
+ }
278
+
279
+ /* ─── Hero inputs ─── */
280
+ #hero-inputs {
281
+ display: flex;
282
+ align-items: flex-start;
283
+ gap: 16px;
284
+ width: 100%;
285
+ max-width: 800px;
286
+ padding: 0 24px;
287
+ }
288
+
289
+ .hero-field {
290
+ flex: 1;
291
+ display: flex;
292
+ flex-direction: column;
293
+ gap: 8px;
294
+ position: relative;
295
+ }
296
+
297
+ .hero-field label {
298
+ font-size: 10px;
299
+ font-weight: 700;
300
+ letter-spacing: 2.5px;
301
+ text-transform: uppercase;
302
+ color: var(--text2);
303
+ padding-left: 2px;
304
+ transition: color 0.25s;
305
+ }
306
+
307
+ .hero-field label.filled {
308
+ color: var(--accent2);
309
+ }
310
+
311
+ .hero-input {
312
+ background: var(--bg3);
313
+ border: 1px solid var(--border);
314
+ border-radius: 6px;
315
+ padding: 14px 16px;
316
+ font-size: 15px;
317
+ font-weight: 500;
318
+ color: var(--text);
319
+ width: 100%;
320
+ outline: none;
321
+ transition: border-color 0.2s, box-shadow 0.2s;
322
+ caret-color: var(--accent2);
323
+ font-family: 'Outfit', sans-serif;
324
+ }
325
+
326
+ .hero-input::placeholder {
327
+ color: rgba(255, 243, 210, .2);
328
+ font-weight: 400;
329
+ font-size: 13px;
330
+ }
331
+
332
+ .hero-input:focus {
333
+ border-color: var(--accent);
334
+ box-shadow: 0 0 0 3px rgba(201, 138, 46, .11);
335
+ }
336
+
337
+ .hero-input.filled {
338
+ border-color: rgba(201, 138, 46, .28);
339
+ }
340
+
341
+ #hero-arrow {
342
+ font-size: 22px;
343
+ color: rgba(255, 243, 210, .14);
344
+ flex-shrink: 0;
345
+ margin-top: 38px;
346
+ transition: color 0.3s ease;
347
+ }
348
+
349
+ #hero-arrow.lit {
350
+ color: var(--accent2);
351
+ }
352
+
353
+ /* ─── Find Path button ─── */
354
+ #hero-btn {
355
+ margin-top: 32px;
356
+ background: var(--accent);
357
+ color: #0d0c08;
358
+ border: none;
359
+ border-radius: 6px;
360
+ padding: 13px 52px;
361
+ font-size: 12px;
362
+ font-weight: 700;
363
+ letter-spacing: 2px;
364
+ text-transform: uppercase;
365
+ cursor: pointer;
366
+ transition: background 0.18s, transform 0.15s;
367
+ opacity: 0;
368
+ pointer-events: none;
369
+ transform: translateY(8px);
370
+ font-family: 'Outfit', sans-serif;
371
+ }
372
+
373
+ #hero-btn.ready {
374
+ opacity: 1;
375
+ pointer-events: auto;
376
+ transform: translateY(0);
377
+ }
378
+
379
+ #hero-btn:hover {
380
+ background: var(--accent2);
381
+ transform: translateY(-1px);
382
+ }
383
+
384
+ #hero-btn:active {
385
+ transform: scale(0.98);
386
+ }
387
+
388
+ /* Hint */
389
+ #hero-hint {
390
+ margin-top: 14px;
391
+ font-size: 11px;
392
+ color: var(--text2);
393
+ opacity: 0;
394
+ transition: opacity 0.3s;
395
+ letter-spacing: 0.3px;
396
+ font-family: 'DM Mono', monospace;
397
+ }
398
+
399
+ #hero-hint.visible {
400
+ opacity: 0.5;
401
+ }
402
+
403
+ /* ─── Random button ─── */
404
+ #hero-random-btn {
405
+ margin: 16px auto 0;
406
+ background: transparent;
407
+ color: var(--text2);
408
+ border: 1px solid var(--border);
409
+ border-radius: 5px;
410
+ padding: 8px 22px;
411
+ font-size: 12px;
412
+ font-weight: 600;
413
+ cursor: pointer;
414
+ transition: all 0.2s ease;
415
+ display: inline-flex;
416
+ align-items: center;
417
+ gap: 6px;
418
+ position: static;
419
+ font-family: 'Outfit', sans-serif;
420
+ }
421
+
422
+ #hero-random-btn:hover {
423
+ border-color: rgba(201, 138, 46, .28);
424
+ color: var(--text);
425
+ }
426
+
427
+ #hero-random-btn:active {
428
+ transform: scale(0.97);
429
+ }
430
+
431
+ #hero-random-btn.loading {
432
+ opacity: 0.5;
433
+ pointer-events: none;
434
+ }
435
+
436
+ /* ─── About button (hero) ─── */
437
+ #hero-about-btn {
438
+ position: absolute;
439
+ top: 20px;
440
+ right: 20px;
441
+ background: transparent;
442
+ color: var(--text2);
443
+ border: 1px solid var(--border);
444
+ border-radius: 5px;
445
+ padding: 7px 16px;
446
+ font-size: 12px;
447
+ font-weight: 600;
448
+ cursor: pointer;
449
+ transition: all 0.2s ease;
450
+ font-family: 'Outfit', sans-serif;
451
+ z-index: 10;
452
+ letter-spacing: 0.4px;
453
+ animation: about-pulse 3.5s ease-in-out infinite;
454
+ }
455
+
456
+ #hero-about-btn:hover {
457
+ color: var(--text);
458
+ border-color: rgba(201, 138, 46, .3);
459
+ animation: none;
460
+ }
461
+
462
+ @keyframes about-pulse {
463
+
464
+ 0%,
465
+ 100% {
466
+ border-color: rgba(255, 243, 210, .08);
467
+ }
468
+
469
+ 50% {
470
+ border-color: rgba(201, 138, 46, .22);
471
+ }
472
+ }
473
+
474
+ /* ─── Watermark ─── */
475
+ #hero-watermark {
476
+ position: absolute;
477
+ top: 50%;
478
+ left: 50%;
479
+ transform: translate(-50%, -52%);
480
+ width: 820px;
481
+ height: 820px;
482
+ opacity: 0.05;
483
+ pointer-events: none;
484
+ }
485
+
486
+ /* z-index stack — same logic as original, just re-declared for specificity */
487
+ #hero>* {
488
+ position: relative;
489
+ z-index: 1;
490
+ }
491
+
492
+ #hero-watermark {
493
+ position: absolute;
494
+ z-index: 0;
495
+ }
496
+
497
+ #hero-bg-mask {
498
+ position: absolute;
499
+ z-index: 1;
500
+ }
501
+
502
+ /* #hero-about-btn shares #hero-about-btn's own rule's specificity (1,0,0)
503
+ with #hero>* above, so the later rule in the file wins and silently
504
+ knocked the button out of its absolute corner position into normal
505
+ flow — same collision already fixed for #hero-watermark/#hero-bg-mask
506
+ above. Re-assert here, after #hero>*, to pin it back to the corner. */
507
+ #hero-about-btn {
508
+ position: absolute;
509
+ top: 20px;
510
+ right: 20px;
511
+ z-index: 10;
512
+ }
513
+
514
+ /* ══════════════════════════════════════════════════════════
515
+ TOP BAR (post-search)
516
+ ══════════════════════════════════════════════════════════ */
517
+ #topbar {
518
+ display: flex;
519
+ align-items: center;
520
+ gap: 10px;
521
+ padding: 0 20px;
522
+ height: 52px;
523
+ background: var(--bg2);
524
+ border-bottom: 1px solid var(--border);
525
+ flex-shrink: 0;
526
+ opacity: 0;
527
+ transform: translateY(-6px);
528
+ transition: opacity 0.35s ease 0.1s, transform 0.35s ease 0.1s;
529
+ }
530
+
531
+ #topbar.visible {
532
+ opacity: 1;
533
+ transform: translateY(0);
534
+ }
535
+
536
+ #topbar h1 {
537
+ font-family: 'Cinzel', serif;
538
+ font-size: 13px;
539
+ font-weight: 700;
540
+ color: var(--text);
541
+ white-space: nowrap;
542
+ letter-spacing: 3.5px;
543
+ text-transform: uppercase;
544
+ }
545
+
546
+ #topbar-logo {
547
+ cursor: pointer;
548
+ user-select: none;
549
+ display: flex;
550
+ align-items: center;
551
+ gap: 8px;
552
+ flex-shrink: 0;
553
+ }
554
+
555
+ .topbar-logo-img {
556
+ height: 20px;
557
+ width: 20px;
558
+ opacity: 0.72;
559
+ transition: opacity 0.2s;
560
+ }
561
+
562
+ #topbar-logo:hover .topbar-logo-img {
563
+ opacity: 1;
564
+ }
565
+
566
+ /* Input wrappers */
567
+ .input-wrap {
568
+ display: flex;
569
+ align-items: center;
570
+ gap: 8px;
571
+ background: var(--bg3);
572
+ border: 1px solid var(--border);
573
+ border-radius: 5px;
574
+ padding: 6px 12px;
575
+ flex: 1;
576
+ min-width: 160px;
577
+ max-width: 340px;
578
+ transition: border-color 0.2s;
579
+ position: relative;
580
+ }
581
+
582
+ .input-wrap:focus-within {
583
+ border-color: rgba(201, 138, 46, .35);
584
+ }
585
+
586
+ .input-wrap span {
587
+ font-size: 9px;
588
+ font-weight: 700;
589
+ letter-spacing: 2px;
590
+ text-transform: uppercase;
591
+ color: var(--accent);
592
+ white-space: nowrap;
593
+ }
594
+
595
+ .input-wrap input {
596
+ background: transparent;
597
+ border: none;
598
+ outline: none;
599
+ color: var(--text);
600
+ font-size: 13px;
601
+ font-weight: 500;
602
+ width: 100%;
603
+ font-family: 'Outfit', sans-serif;
604
+ }
605
+
606
+ /* Topbar buttons */
607
+ #btn-swap {
608
+ background: transparent;
609
+ color: var(--text2);
610
+ border: 1px solid var(--border);
611
+ border-radius: 5px;
612
+ padding: 6px 10px;
613
+ font-size: 14px;
614
+ cursor: pointer;
615
+ transition: all 0.2s;
616
+ flex-shrink: 0;
617
+ line-height: 1;
618
+ }
619
+
620
+ #btn-swap:hover {
621
+ color: var(--text);
622
+ border-color: rgba(201, 138, 46, .25);
623
+ }
624
+
625
+ #btn-search {
626
+ background: var(--accent);
627
+ color: #0d0c08;
628
+ border: none;
629
+ border-radius: 5px;
630
+ padding: 8px 18px;
631
+ font-size: 11px;
632
+ font-weight: 700;
633
+ letter-spacing: 1.2px;
634
+ text-transform: uppercase;
635
+ cursor: pointer;
636
+ transition: background 0.18s;
637
+ white-space: nowrap;
638
+ font-family: 'Outfit', sans-serif;
639
+ flex-shrink: 0;
640
+ }
641
+
642
+ #btn-search:hover {
643
+ background: var(--accent2);
644
+ }
645
+
646
+ #btn-search:disabled {
647
+ opacity: .35;
648
+ cursor: default;
649
+ }
650
+
651
+ #btn-reset {
652
+ background: transparent;
653
+ color: var(--text2);
654
+ border: 1px solid var(--border);
655
+ border-radius: 5px;
656
+ padding: 7px 14px;
657
+ font-size: 12px;
658
+ font-weight: 600;
659
+ cursor: pointer;
660
+ transition: all 0.2s;
661
+ font-family: 'Outfit', sans-serif;
662
+ flex-shrink: 0;
663
+ }
664
+
665
+ #btn-reset:hover {
666
+ color: var(--text);
667
+ border-color: rgba(201, 138, 46, .25);
668
+ }
669
+
670
+ #btn-about {
671
+ background: transparent;
672
+ color: var(--text2);
673
+ border: 1px solid var(--border);
674
+ border-radius: 5px;
675
+ padding: 7px 14px;
676
+ font-size: 12px;
677
+ font-weight: 600;
678
+ cursor: pointer;
679
+ transition: all 0.2s;
680
+ font-family: 'Outfit', sans-serif;
681
+ flex-shrink: 0;
682
+ }
683
+
684
+ #btn-about:hover {
685
+ color: var(--text);
686
+ border-color: rgba(201, 138, 46, .25);
687
+ }
688
+
689
+ #status-bar {
690
+ font-size: 11px;
691
+ color: var(--text2);
692
+ flex: 1;
693
+ min-width: 80px;
694
+ font-weight: 400;
695
+ font-family: 'DM Mono', monospace;
696
+ white-space: nowrap;
697
+ overflow: hidden;
698
+ text-overflow: ellipsis;
699
+ padding-left: 4px;
700
+ }
701
+
702
+ /* ══════════════════════════════════════════════════════════
703
+ STATS BAR
704
+ ══════════════════════════��═══════════════════════════════ */
705
+ #stats-bar {
706
+ display: none;
707
+ align-items: center;
708
+ gap: 16px;
709
+ padding: 5px 20px;
710
+ background: var(--bg2);
711
+ border-bottom: 1px solid var(--border);
712
+ flex-shrink: 0;
713
+ }
714
+
715
+ #stats-bar.visible {
716
+ display: flex;
717
+ }
718
+
719
+ .sstat {
720
+ display: flex;
721
+ align-items: center;
722
+ gap: 6px;
723
+ font-size: 11px;
724
+ color: var(--text2);
725
+ font-weight: 400;
726
+ font-family: 'DM Mono', monospace;
727
+ }
728
+
729
+ .sstat-val {
730
+ color: var(--accent2);
731
+ font-weight: 500;
732
+ font-size: 11px;
733
+ min-width: 22px;
734
+ }
735
+
736
+ .sstat-dot {
737
+ width: 6px;
738
+ height: 6px;
739
+ border-radius: 50%;
740
+ flex-shrink: 0;
741
+ }
742
+
743
+ .stats-spacer {
744
+ flex: 1;
745
+ }
746
+
747
+ #btn-pause-bar {
748
+ background: transparent;
749
+ color: var(--text2);
750
+ border: 1px solid var(--border);
751
+ border-radius: 4px;
752
+ padding: 4px 14px;
753
+ font-size: 11px;
754
+ font-weight: 600;
755
+ cursor: pointer;
756
+ transition: all 0.2s;
757
+ display: none;
758
+ font-family: 'Outfit', sans-serif;
759
+ }
760
+
761
+ #btn-pause-bar:hover {
762
+ color: var(--text);
763
+ border-color: rgba(201, 138, 46, .25);
764
+ }
765
+
766
+ #btn-pause-bar.visible {
767
+ display: inline-block;
768
+ }
769
+
770
+ #btn-pause-bar.paused {
771
+ color: var(--amber);
772
+ border-color: var(--amber);
773
+ }
774
+
775
+ #btn-export {
776
+ background: transparent;
777
+ color: var(--text2);
778
+ border: 1px solid var(--border);
779
+ border-radius: 4px;
780
+ padding: 4px 12px;
781
+ font-size: 11px;
782
+ font-weight: 600;
783
+ cursor: pointer;
784
+ transition: all 0.2s;
785
+ display: none;
786
+ font-family: 'Outfit', sans-serif;
787
+ }
788
+
789
+ #btn-export:hover {
790
+ color: var(--green);
791
+ border-color: var(--green);
792
+ }
793
+
794
+ #btn-export.visible {
795
+ display: inline-block;
796
+ }
797
+
798
+ /* ══════════════════════════════════════════════════════════
799
+ MAIN LAYOUT
800
+ ══════════════════════════════════════════════════════════ */
801
+ #main {
802
+ display: flex;
803
+ flex: 1;
804
+ overflow: hidden;
805
+ }
806
+
807
+ #canvas-wrap {
808
+ flex: 1;
809
+ position: relative;
810
+ overflow: hidden;
811
+ background: var(--bg);
812
+ }
813
+
814
+ svg#graph {
815
+ width: 100%;
816
+ height: 100%;
817
+ }
818
+
819
+ /* ─── Tooltip ─── */
820
+ #tooltip {
821
+ position: absolute;
822
+ pointer-events: none;
823
+ background: var(--bg2);
824
+ border: 1px solid var(--border);
825
+ border-top: 1px solid rgba(255, 243, 210, .1);
826
+ box-shadow: var(--shadow-md);
827
+ border-radius: 6px;
828
+ padding: 10px 14px;
829
+ font-size: 12px;
830
+ color: var(--text);
831
+ max-width: 220px;
832
+ opacity: 0;
833
+ transition: opacity .12s;
834
+ z-index: 100;
835
+ }
836
+
837
+ #tooltip.visible {
838
+ opacity: 1;
839
+ }
840
+
841
+ #tooltip .tt-title {
842
+ font-weight: 600;
843
+ margin-bottom: 6px;
844
+ color: var(--accent2);
845
+ font-size: 13px;
846
+ font-family: 'Outfit', sans-serif;
847
+ }
848
+
849
+ #tooltip .tt-scores {
850
+ color: var(--text2);
851
+ font-size: 11px;
852
+ line-height: 1.55;
853
+ font-family: 'DM Mono', monospace;
854
+ }
855
+
856
+ /* ─── Node labels ─── */
857
+ .node-label {
858
+ font-size: 10px;
859
+ fill: var(--text2);
860
+ pointer-events: none;
861
+ text-anchor: middle;
862
+ font-weight: 500;
863
+ font-family: 'Outfit', sans-serif;
864
+ }
865
+
866
+ .node-label.centre-label {
867
+ font-size: 12px;
868
+ fill: var(--text);
869
+ font-weight: 700;
870
+ }
871
+
872
+ /* ══════════════════════════════════════════════════════════
873
+ SIDE PANEL
874
+ ══════════════════════════════════════════════════════════ */
875
+ #panel {
876
+ width: 300px;
877
+ background: var(--bg2);
878
+ border-left: 1px solid var(--border);
879
+ display: flex;
880
+ flex-direction: column;
881
+ overflow: hidden;
882
+ flex-shrink: 0;
883
+ }
884
+
885
+ #panel-tabs {
886
+ display: flex;
887
+ border-bottom: 1px solid var(--border);
888
+ }
889
+
890
+ .tab {
891
+ flex: 1;
892
+ text-align: center;
893
+ padding: 12px 0;
894
+ font-size: 10px;
895
+ font-weight: 700;
896
+ letter-spacing: 1.8px;
897
+ text-transform: uppercase;
898
+ color: var(--text2);
899
+ cursor: pointer;
900
+ border-bottom: 2px solid transparent;
901
+ transition: all 0.18s ease;
902
+ }
903
+
904
+ .tab:hover {
905
+ color: var(--text);
906
+ }
907
+
908
+ .tab.active {
909
+ color: var(--accent2);
910
+ border-bottom-color: var(--accent);
911
+ }
912
+
913
+ .tab-content {
914
+ display: none;
915
+ flex: 1;
916
+ overflow-y: auto;
917
+ padding: 14px;
918
+ }
919
+
920
+ .tab-content.active {
921
+ display: flex;
922
+ flex-direction: column;
923
+ gap: 8px;
924
+ }
925
+
926
+ /* ─── Path steps ─── */
927
+ #path-display {
928
+ display: flex;
929
+ flex-direction: column;
930
+ gap: 4px;
931
+ }
932
+
933
+ .path-step {
934
+ display: flex;
935
+ align-items: center;
936
+ gap: 4px;
937
+ font-size: 12px;
938
+ }
939
+
940
+ .path-step .dot {
941
+ width: 7px;
942
+ height: 7px;
943
+ border-radius: 50%;
944
+ flex-shrink: 0;
945
+ background: var(--node-path);
946
+ }
947
+
948
+ .path-step .dot.start {
949
+ background: var(--accent);
950
+ }
951
+
952
+ .path-step .dot.end {
953
+ background: var(--green);
954
+ }
955
+
956
+ .path-step .arrow {
957
+ color: var(--text2);
958
+ font-size: 12px;
959
+ }
960
+
961
+ .hop-btn-link {
962
+ display: inline-flex;
963
+ align-items: center;
964
+ background: var(--bg3);
965
+ border: 1px solid var(--border);
966
+ color: var(--text);
967
+ padding: 5px 10px;
968
+ border-radius: 4px;
969
+ font-size: 12px;
970
+ font-weight: 500;
971
+ text-decoration: none;
972
+ transition: all 0.15s;
973
+ margin-left: 6px;
974
+ flex: 1;
975
+ cursor: pointer;
976
+ }
977
+
978
+ .hop-btn-link:hover {
979
+ border-color: rgba(201, 138, 46, .3);
980
+ color: var(--accent2);
981
+ }
982
+
983
+ .hop-btn-link.start-hop {
984
+ border-color: rgba(201, 138, 46, .22);
985
+ }
986
+
987
+ .hop-btn-link.end-hop {
988
+ border-color: rgba(74, 171, 121, .22);
989
+ }
990
+
991
+ .hop-btn-link.end-hop:hover {
992
+ border-color: var(--green);
993
+ color: var(--green);
994
+ }
995
+
996
+ /* ─── Score cards ─── */
997
+ .score-card {
998
+ background: var(--bg3);
999
+ border: 1px solid var(--border);
1000
+ border-radius: 5px;
1001
+ padding: 11px 12px;
1002
+ transition: border-color 0.15s;
1003
+ }
1004
+
1005
+ .score-card:hover {
1006
+ border-color: rgba(201, 138, 46, .2);
1007
+ }
1008
+
1009
+ .score-card .title {
1010
+ font-size: 12px;
1011
+ font-weight: 600;
1012
+ color: var(--text);
1013
+ margin-bottom: 7px;
1014
+ word-break: break-word;
1015
+ line-height: 1.3;
1016
+ }
1017
+
1018
+ .score-row {
1019
+ display: flex;
1020
+ justify-content: space-between;
1021
+ font-size: 11px;
1022
+ color: var(--text2);
1023
+ margin-top: 3px;
1024
+ font-family: 'DM Mono', monospace;
1025
+ }
1026
+
1027
+ .score-row span:last-child {
1028
+ color: var(--accent2);
1029
+ font-weight: 500;
1030
+ }
1031
+
1032
+ /* ─── Log ─── */
1033
+ #log {
1034
+ display: flex;
1035
+ flex-direction: column;
1036
+ gap: 0;
1037
+ }
1038
+
1039
+ .log-entry {
1040
+ font-family: 'DM Mono', monospace;
1041
+ font-size: 11px;
1042
+ color: var(--text2);
1043
+ padding: 5px 0;
1044
+ border-bottom: 1px solid var(--border);
1045
+ line-height: 1.55;
1046
+ }
1047
+
1048
+ .log-entry.highlight {
1049
+ color: var(--amber);
1050
+ }
1051
+
1052
+ .log-entry.success {
1053
+ color: var(--green);
1054
+ }
1055
+
1056
+ .log-entry.error {
1057
+ color: var(--red);
1058
+ }
1059
+
1060
+ /* ─── Legend ─── */
1061
+ .legend-row {
1062
+ display: flex;
1063
+ align-items: center;
1064
+ gap: 10px;
1065
+ font-size: 12px;
1066
+ color: var(--text2);
1067
+ line-height: 1.4;
1068
+ }
1069
+
1070
+ .legend-dot {
1071
+ width: 10px;
1072
+ height: 10px;
1073
+ border-radius: 50%;
1074
+ flex-shrink: 0;
1075
+ }
1076
+
1077
+ /* ─── Path banner ─── */
1078
+ #path-banner {
1079
+ display: none;
1080
+ background: var(--bg2);
1081
+ border-top: 1px solid var(--border);
1082
+ padding: 10px 20px;
1083
+ flex-shrink: 0;
1084
+ }
1085
+
1086
+ #path-banner.visible {
1087
+ display: flex;
1088
+ align-items: center;
1089
+ gap: 6px;
1090
+ flex-wrap: wrap;
1091
+ }
1092
+
1093
+ .banner-arrow {
1094
+ color: var(--accent);
1095
+ font-size: 13px;
1096
+ font-weight: 700;
1097
+ }
1098
+
1099
+ /* ══════════════════════════════════════════════════════════
1100
+ AUTOCOMPLETE DROPDOWN
1101
+ ══════════════════════════════════════════════════════════ */
1102
+ .hero-field {
1103
+ position: relative;
1104
+ }
1105
+
1106
+ .ac-dropdown {
1107
+ position: absolute;
1108
+ top: calc(100% + 4px);
1109
+ left: 0;
1110
+ right: 0;
1111
+ background: var(--bg2);
1112
+ border: 1px solid var(--border-bright);
1113
+ border-radius: 6px;
1114
+ overflow: hidden;
1115
+ z-index: 50;
1116
+ box-shadow: var(--shadow-lg);
1117
+ display: none;
1118
+ }
1119
+
1120
+ .ac-dropdown.visible {
1121
+ display: block;
1122
+ }
1123
+
1124
+ .ac-item {
1125
+ padding: 10px 14px;
1126
+ font-size: 13px;
1127
+ font-weight: 500;
1128
+ color: var(--text);
1129
+ cursor: pointer;
1130
+ transition: background 0.1s;
1131
+ border-bottom: 1px solid var(--border);
1132
+ display: flex;
1133
+ align-items: center;
1134
+ gap: 8px;
1135
+ }
1136
+
1137
+ .ac-item:last-child {
1138
+ border-bottom: none;
1139
+ }
1140
+
1141
+ .ac-item:hover,
1142
+ .ac-item.ac-active {
1143
+ background: rgba(201, 138, 46, .08);
1144
+ color: var(--accent2);
1145
+ }
1146
+
1147
+ .ac-item .ac-icon {
1148
+ opacity: 0.38;
1149
+ flex-shrink: 0;
1150
+ }
1151
+
1152
+ .input-wrap {
1153
+ position: relative;
1154
+ }
1155
+
1156
+ /* ══════════════════════════════════════════════════════════
1157
+ MODALS
1158
+ ══════════════════════════════════════════════════════════ */
1159
+ .modal-backdrop {
1160
+ position: fixed;
1161
+ inset: 0;
1162
+ background: rgba(13, 12, 8, .84);
1163
+ backdrop-filter: blur(6px);
1164
+ z-index: 1000;
1165
+ display: flex;
1166
+ align-items: center;
1167
+ justify-content: center;
1168
+ opacity: 0;
1169
+ pointer-events: none;
1170
+ transition: opacity 0.3s ease;
1171
+ }
1172
+
1173
+ .modal-backdrop.visible {
1174
+ opacity: 1;
1175
+ pointer-events: auto;
1176
+ }
1177
+
1178
+ .modal-card {
1179
+ position: relative;
1180
+ background: var(--bg2);
1181
+ border: 1px solid var(--border);
1182
+ border-top: 1px solid rgba(255, 243, 210, .12);
1183
+ box-shadow: var(--shadow-xl);
1184
+ border-radius: 10px;
1185
+ padding: 40px;
1186
+ max-width: 860px;
1187
+ width: 90%;
1188
+ transform: scale(0.96) translateY(10px);
1189
+ transition: transform 0.38s cubic-bezier(0.34, 1.56, 0.64, 1);
1190
+ text-align: center;
1191
+ }
1192
+
1193
+ /* ─── Corner close (×) — onboarding + success modals ─── */
1194
+ .modal-corner-close {
1195
+ position: absolute;
1196
+ top: 14px;
1197
+ right: 14px;
1198
+ width: 28px;
1199
+ height: 28px;
1200
+ display: flex;
1201
+ align-items: center;
1202
+ justify-content: center;
1203
+ background: transparent;
1204
+ border: 1px solid var(--border);
1205
+ border-radius: 50%;
1206
+ color: var(--text2);
1207
+ cursor: pointer;
1208
+ transition: all 0.18s ease;
1209
+ padding: 0;
1210
+ z-index: 2;
1211
+ }
1212
+
1213
+ .modal-corner-close svg {
1214
+ width: 12px;
1215
+ height: 12px;
1216
+ }
1217
+
1218
+ .modal-corner-close:hover {
1219
+ color: var(--text);
1220
+ border-color: rgba(201, 138, 46, .35);
1221
+ background: rgba(201, 138, 46, .06);
1222
+ }
1223
+
1224
+ .modal-backdrop.visible .modal-card {
1225
+ transform: scale(1) translateY(0);
1226
+ }
1227
+
1228
+ .modal-success-icon {
1229
+ width: 40px;
1230
+ height: 40px;
1231
+ color: var(--green);
1232
+ margin-bottom: 10px;
1233
+ }
1234
+
1235
+ .modal-title {
1236
+ font-family: 'Cinzel', serif;
1237
+ font-size: 26px;
1238
+ font-weight: 700;
1239
+ color: var(--green);
1240
+ margin-bottom: 6px;
1241
+ letter-spacing: 1px;
1242
+ }
1243
+
1244
+ .modal-subtitle {
1245
+ font-size: 14px;
1246
+ color: var(--text2);
1247
+ margin-bottom: 32px;
1248
+ }
1249
+
1250
+ .modal-subtitle span {
1251
+ font-weight: 700;
1252
+ color: var(--text);
1253
+ }
1254
+
1255
+ .modal-stat-row {
1256
+ display: flex;
1257
+ justify-content: center;
1258
+ gap: 44px;
1259
+ margin: 18px 0 24px;
1260
+ }
1261
+
1262
+ .modal-stat {
1263
+ display: flex;
1264
+ flex-direction: column;
1265
+ align-items: center;
1266
+ gap: 5px;
1267
+ }
1268
+
1269
+ .modal-stat-val {
1270
+ font-size: 34px;
1271
+ font-weight: 500;
1272
+ color: var(--text);
1273
+ font-family: 'DM Mono', monospace;
1274
+ line-height: 1;
1275
+ }
1276
+
1277
+ .modal-stat-lbl {
1278
+ font-size: 9px;
1279
+ font-weight: 700;
1280
+ letter-spacing: 2.5px;
1281
+ text-transform: uppercase;
1282
+ color: var(--text2);
1283
+ }
1284
+
1285
+ .modal-path {
1286
+ display: flex;
1287
+ flex-wrap: wrap;
1288
+ align-items: center;
1289
+ justify-content: center;
1290
+ gap: 8px;
1291
+ margin: 0 0 28px;
1292
+ padding: 20px 24px;
1293
+ background: var(--bg3);
1294
+ border-radius: 6px;
1295
+ border: 1px solid var(--border);
1296
+ }
1297
+
1298
+ .modal-arrow {
1299
+ font-size: 15px;
1300
+ font-weight: 600;
1301
+ color: var(--text2);
1302
+ position: relative;
1303
+ display: inline-flex;
1304
+ flex-direction: column;
1305
+ align-items: center;
1306
+ gap: 2px;
1307
+ cursor: default;
1308
+ }
1309
+
1310
+ .modal-piped-note {
1311
+ font-size: 9px;
1312
+ font-weight: 600;
1313
+ font-style: italic;
1314
+ color: var(--amber);
1315
+ white-space: nowrap;
1316
+ letter-spacing: 0.2px;
1317
+ }
1318
+
1319
+ .modal-disclaimer {
1320
+ font-size: 11px;
1321
+ line-height: 1.65;
1322
+ color: var(--text2);
1323
+ opacity: 0.55;
1324
+ text-align: center;
1325
+ max-width: 460px;
1326
+ margin: -8px auto 24px;
1327
+ }
1328
+
1329
+ .modal-close-btn {
1330
+ background: var(--accent);
1331
+ color: #0d0c08;
1332
+ border: none;
1333
+ padding: 12px 30px;
1334
+ border-radius: 5px;
1335
+ font-size: 11px;
1336
+ font-weight: 700;
1337
+ letter-spacing: 1.2px;
1338
+ text-transform: uppercase;
1339
+ cursor: pointer;
1340
+ transition: background 0.18s;
1341
+ font-family: 'Outfit', sans-serif;
1342
+ }
1343
+
1344
+ .modal-close-btn:hover {
1345
+ background: var(--accent2);
1346
+ }
1347
+
1348
+ .modal-copy-btn {
1349
+ background: transparent;
1350
+ color: var(--text2);
1351
+ border: 1px solid var(--border);
1352
+ padding: 12px 30px;
1353
+ border-radius: 5px;
1354
+ font-size: 11px;
1355
+ font-weight: 600;
1356
+ letter-spacing: 0.5px;
1357
+ cursor: pointer;
1358
+ transition: all 0.18s;
1359
+ font-family: 'Outfit', sans-serif;
1360
+ }
1361
+
1362
+ .modal-copy-btn:hover {
1363
+ color: var(--text);
1364
+ border-color: rgba(201, 138, 46, .3);
1365
+ }
1366
+
1367
+ .hop-btn {
1368
+ display: inline-flex;
1369
+ align-items: center;
1370
+ gap: 6px;
1371
+ background: var(--bg3);
1372
+ border: 1px solid var(--border);
1373
+ color: var(--text);
1374
+ padding: 9px 16px;
1375
+ border-radius: 5px;
1376
+ font-size: 13px;
1377
+ font-weight: 600;
1378
+ text-decoration: none;
1379
+ transition: all 0.15s;
1380
+ cursor: pointer;
1381
+ }
1382
+
1383
+ .hop-btn:hover {
1384
+ border-color: rgba(201, 138, 46, .35);
1385
+ color: var(--accent2);
1386
+ }
1387
+
1388
+ .hop-btn.start-hop {
1389
+ border-color: rgba(201, 138, 46, .28);
1390
+ }
1391
+
1392
+ .hop-btn.end-hop {
1393
+ border-color: rgba(74, 171, 121, .28);
1394
+ }
1395
+
1396
+ .hop-btn.end-hop:hover {
1397
+ border-color: var(--green);
1398
+ color: var(--green);
1399
+ }
1400
+
1401
+ /* ══════════════════════════════════════════════════════════
1402
+ PATH FOUND ANIMATION
1403
+ ══════════════════════════════════════════════════════════ */
1404
+ @keyframes pathEdgePulse {
1405
+
1406
+ 0%,
1407
+ 100% {
1408
+ stroke-opacity: 0.8;
1409
+ }
1410
+
1411
+ 50% {
1412
+ stroke-opacity: 1.0;
1413
+ }
1414
+ }
1415
+
1416
+ .edge-path-found {
1417
+ animation: pathEdgePulse 1.6s ease-in-out infinite;
1418
+ }
1419
+
1420
+ @keyframes modalEnter {
1421
+ 0% {
1422
+ opacity: 0;
1423
+ transform: scale(0.92) translateY(14px);
1424
+ }
1425
+
1426
+ 60% {
1427
+ opacity: 1;
1428
+ transform: scale(1.01) translateY(-2px);
1429
+ }
1430
+
1431
+ 100% {
1432
+ transform: scale(1) translateY(0);
1433
+ }
1434
+ }
1435
+
1436
+ .modal-backdrop.visible .modal-card {
1437
+ animation: modalEnter 0.45s cubic-bezier(0.22, 1, 0.36, 1) forwards !important;
1438
+ }
1439
+
1440
+ /* ══════════════════════════════════════════════════════════
1441
+ ONBOARDING MODAL
1442
+ ══════════════════════════════════════════════════════════ */
1443
+ #onboarding-modal {
1444
+ z-index: 600;
1445
+ display: none;
1446
+ }
1447
+
1448
+ #onboarding-modal.visible {
1449
+ display: flex;
1450
+ }
1451
+
1452
+ .onboarding-card {
1453
+ max-width: 460px;
1454
+ text-align: center;
1455
+ padding: 44px 40px 36px;
1456
+ }
1457
+
1458
+ .onboarding-title {
1459
+ font-family: 'Cinzel', serif;
1460
+ font-size: 22px;
1461
+ font-weight: 900;
1462
+ letter-spacing: 4px;
1463
+ text-transform: uppercase;
1464
+ color: var(--text);
1465
+ margin-bottom: 6px;
1466
+ }
1467
+
1468
+ .onboarding-tagline {
1469
+ font-size: 10px;
1470
+ font-weight: 600;
1471
+ letter-spacing: 3px;
1472
+ text-transform: uppercase;
1473
+ color: var(--accent);
1474
+ opacity: 0.75;
1475
+ margin-bottom: 32px;
1476
+ }
1477
+
1478
+ .onboarding-steps {
1479
+ display: flex;
1480
+ flex-direction: column;
1481
+ gap: 18px;
1482
+ text-align: left;
1483
+ margin-bottom: 32px;
1484
+ }
1485
+
1486
+ .onboarding-step {
1487
+ display: flex;
1488
+ gap: 14px;
1489
+ align-items: flex-start;
1490
+ }
1491
+
1492
+ .onboarding-icon {
1493
+ flex-shrink: 0;
1494
+ width: 28px;
1495
+ height: 28px;
1496
+ border-radius: 50%;
1497
+ background: var(--bg3);
1498
+ border: 1px solid rgba(201, 138, 46, .3);
1499
+ color: var(--accent2);
1500
+ font-weight: 700;
1501
+ font-size: 13px;
1502
+ display: flex;
1503
+ align-items: center;
1504
+ justify-content: center;
1505
+ }
1506
+
1507
+ .onboarding-step strong {
1508
+ font-size: 13px;
1509
+ font-weight: 700;
1510
+ color: var(--text);
1511
+ display: block;
1512
+ margin-bottom: 3px;
1513
+ }
1514
+
1515
+ .onboarding-step p {
1516
+ font-size: 12px;
1517
+ color: var(--text2);
1518
+ line-height: 1.6;
1519
+ margin: 0;
1520
+ }
1521
+
1522
+ .onboarding-go-btn {
1523
+ font-size: 13px;
1524
+ padding: 11px 36px;
1525
+ }
1526
+
1527
+ .onboarding-tip {
1528
+ font-size: 11.5px;
1529
+ font-style: italic;
1530
+ color: var(--text2);
1531
+ opacity: 0.7;
1532
+ line-height: 1.6;
1533
+ margin: -6px 0 22px;
1534
+ }
1535
+
1536
+ /* ══════════════════════════════════════════════════════════
1537
+ ABOUT MODAL
1538
+ ══════════════════════════════════════════════════════════ */
1539
+ .about-card {
1540
+ max-width: 720px;
1541
+ text-align: left;
1542
+ max-height: 88vh;
1543
+ overflow-y: auto;
1544
+ padding: 44px 48px;
1545
+ scrollbar-width: thin;
1546
+ }
1547
+
1548
+ .about-card::-webkit-scrollbar {
1549
+ width: 4px;
1550
+ }
1551
+
1552
+ .about-card::-webkit-scrollbar-thumb {
1553
+ background: rgba(255, 243, 210, .1);
1554
+ border-radius: 2px;
1555
+ }
1556
+
1557
+ .about-section-label {
1558
+ font-size: 9px;
1559
+ font-weight: 700;
1560
+ letter-spacing: 3.5px;
1561
+ text-transform: uppercase;
1562
+ color: var(--accent);
1563
+ margin-bottom: 16px;
1564
+ display: block;
1565
+ }
1566
+
1567
+ .about-quote-block {
1568
+ text-align: center;
1569
+ padding: 28px 24px;
1570
+ background: var(--bg3);
1571
+ border: 1px solid var(--border);
1572
+ border-left: 2px solid var(--accent);
1573
+ border-radius: 2px 8px 8px 2px;
1574
+ margin-bottom: 28px;
1575
+ }
1576
+
1577
+ .about-quote-mark {
1578
+ font-family: 'Cinzel', serif;
1579
+ font-size: 44px;
1580
+ line-height: 0.6;
1581
+ color: var(--accent);
1582
+ opacity: 0.28;
1583
+ display: block;
1584
+ margin-bottom: 14px;
1585
+ user-select: none;
1586
+ }
1587
+
1588
+ .about-quote-text {
1589
+ font-size: 15px;
1590
+ font-style: italic;
1591
+ color: var(--text);
1592
+ font-weight: 400;
1593
+ line-height: 1.8;
1594
+ margin-bottom: 12px;
1595
+ }
1596
+
1597
+ .about-quote-attr {
1598
+ font-size: 10px;
1599
+ font-weight: 700;
1600
+ letter-spacing: 2px;
1601
+ text-transform: uppercase;
1602
+ color: var(--accent2);
1603
+ opacity: 0.65;
1604
+ }
1605
+
1606
+ .about-tech-grid {
1607
+ display: grid;
1608
+ grid-template-columns: 1fr 1fr;
1609
+ gap: 8px;
1610
+ margin-bottom: 28px;
1611
+ }
1612
+
1613
+ .about-tech-card {
1614
+ background: var(--bg3);
1615
+ border: 1px solid var(--border);
1616
+ border-radius: 6px;
1617
+ padding: 16px 18px;
1618
+ transition: border-color 0.18s;
1619
+ }
1620
+
1621
+ .about-tech-card:hover {
1622
+ border-color: rgba(201, 138, 46, .22);
1623
+ }
1624
+
1625
+ .tech-icon {
1626
+ width: 26px;
1627
+ height: 26px;
1628
+ margin-bottom: 10px;
1629
+ color: var(--accent2);
1630
+ opacity: 0.7;
1631
+ }
1632
+
1633
+ .about-tech-card .tech-name {
1634
+ font-size: 12px;
1635
+ font-weight: 700;
1636
+ color: var(--text);
1637
+ margin-bottom: 6px;
1638
+ }
1639
+
1640
+ .about-tech-card .tech-desc {
1641
+ font-size: 11.5px;
1642
+ color: var(--text2);
1643
+ line-height: 1.7;
1644
+ }
1645
+
1646
+ .about-tech-card .tech-desc em {
1647
+ color: var(--text);
1648
+ font-style: normal;
1649
+ font-weight: 600;
1650
+ }
1651
+
1652
+ .about-compare {
1653
+ display: grid;
1654
+ grid-template-columns: 1fr 1fr;
1655
+ border: 1px solid var(--border);
1656
+ border-radius: 6px;
1657
+ overflow: hidden;
1658
+ margin-bottom: 4px;
1659
+ }
1660
+
1661
+ .about-compare-head {
1662
+ padding: 10px 14px;
1663
+ font-size: 9px;
1664
+ font-weight: 700;
1665
+ letter-spacing: 2.5px;
1666
+ text-transform: uppercase;
1667
+ color: var(--text2);
1668
+ background: var(--bg3);
1669
+ border-bottom: 1px solid var(--border);
1670
+ }
1671
+
1672
+ .about-compare-head.accent {
1673
+ color: var(--accent2);
1674
+ }
1675
+
1676
+ .about-compare-cell {
1677
+ padding: 10px 14px;
1678
+ font-size: 12px;
1679
+ color: var(--text2);
1680
+ background: var(--bg2);
1681
+ line-height: 1.5;
1682
+ border-bottom: 1px solid var(--border);
1683
+ }
1684
+
1685
+ .about-compare-cell.accent {
1686
+ color: var(--text);
1687
+ background: rgba(201, 138, 46, .025);
1688
+ }
1689
+
1690
+ .about-prose {
1691
+ font-size: 13px;
1692
+ color: var(--text2);
1693
+ line-height: 1.85;
1694
+ margin-bottom: 14px;
1695
+ }
1696
+
1697
+ .about-prose b {
1698
+ color: var(--text);
1699
+ }
1700
+
1701
+ .about-divider {
1702
+ border: none;
1703
+ border-top: 1px solid var(--border);
1704
+ margin: 24px 0;
1705
+ }
1706
+
1707
+ .about-linkedin-btn {
1708
+ display: inline-flex;
1709
+ align-items: center;
1710
+ gap: 6px;
1711
+ color: var(--accent2);
1712
+ font-size: 12px;
1713
+ font-weight: 700;
1714
+ text-decoration: none;
1715
+ border: 1px solid rgba(201, 138, 46, .28);
1716
+ padding: 8px 16px;
1717
+ border-radius: 5px;
1718
+ transition: all 0.2s;
1719
+ font-family: 'Outfit', sans-serif;
1720
+ }
1721
+
1722
+ .about-linkedin-btn:hover {
1723
+ background: rgba(201, 138, 46, .06);
1724
+ border-color: rgba(201, 138, 46, .48);
1725
+ }
1726
+
1727
+ .about-callout {
1728
+ background: var(--bg3);
1729
+ border-left: 2px solid var(--accent);
1730
+ padding: 16px 20px;
1731
+ margin-bottom: 4px;
1732
+ }
1733
+
1734
+ .about-callout-quote {
1735
+ font-size: 14px;
1736
+ font-style: italic;
1737
+ color: var(--text);
1738
+ font-weight: 400;
1739
+ line-height: 1.65;
1740
+ margin-bottom: 8px;
1741
+ }
1742
+
1743
+ .about-callout-attr {
1744
+ font-size: 9px;
1745
+ font-weight: 700;
1746
+ letter-spacing: 2px;
1747
+ text-transform: uppercase;
1748
+ color: var(--accent2);
1749
+ }
1750
+
1751
+ /* ══════════════════════════════════════════════════════════
1752
+ ANIMATED BRAND MARK — constellation graph logo
1753
+ ══════════════════════════════════════════════════════════ */
1754
+ .aurelius-logo {
1755
+ overflow: visible;
1756
+ }
1757
+
1758
+ .alogo-edges line {
1759
+ stroke: var(--accent);
1760
+ stroke-width: 1.5;
1761
+ stroke-linecap: round;
1762
+ }
1763
+
1764
+ .alogo-nodes circle {
1765
+ fill: var(--accent);
1766
+ stroke: rgba(255, 243, 210, .15);
1767
+ stroke-width: 0.8;
1768
+ }
1769
+
1770
+ .alogo-hub {
1771
+ fill: var(--node-path);
1772
+ stroke: rgba(255, 243, 210, .2);
1773
+ stroke-width: 1;
1774
+ }
1775
+
1776
+ .alogo-trace {
1777
+ fill: none;
1778
+ stroke: var(--node-path);
1779
+ stroke-width: 2;
1780
+ stroke-linecap: round;
1781
+ stroke-linejoin: round;
1782
+ stroke-dasharray: 200;
1783
+ animation: trace-draw 4s ease-in-out infinite;
1784
+ }
1785
+
1786
+ @keyframes trace-draw {
1787
+ 0% {
1788
+ stroke-dashoffset: 200;
1789
+ opacity: 0;
1790
+ }
1791
+
1792
+ 10% {
1793
+ opacity: 1;
1794
+ }
1795
+
1796
+ 50% {
1797
+ stroke-dashoffset: 0;
1798
+ opacity: 1;
1799
+ }
1800
+
1801
+ 80% {
1802
+ stroke-dashoffset: 0;
1803
+ opacity: 0.3;
1804
+ }
1805
+
1806
+ 100% {
1807
+ stroke-dashoffset: -200;
1808
+ opacity: 0;
1809
+ }
1810
+ }
1811
+
1812
+ /* ─── Hero watermark version ─── */
1813
+ .aurelius-logo-hero {
1814
+ overflow: visible;
1815
+ }
1816
+
1817
+ .wm-edges line {
1818
+ stroke: var(--accent);
1819
+ stroke-width: 0.8;
1820
+ stroke-linecap: round;
1821
+ opacity: 0.6;
1822
+ }
1823
+
1824
+ .wm-nodes circle {
1825
+ fill: var(--accent);
1826
+ stroke: rgba(255, 243, 210, .08);
1827
+ stroke-width: 0.5;
1828
+ opacity: 0.6;
1829
+ }
1830
+
1831
+ .wm-hub {
1832
+ fill: var(--node-path);
1833
+ stroke: rgba(255, 243, 210, .15);
1834
+ stroke-width: 0.7;
1835
+ animation: wm-hub-pulse 4s ease-in-out infinite;
1836
+ }
1837
+
1838
+ @keyframes wm-hub-pulse {
1839
+
1840
+ 0%,
1841
+ 100% {
1842
+ r: 6.5;
1843
+ }
1844
+
1845
+ 50% {
1846
+ r: 8;
1847
+ }
1848
+ }
1849
+
1850
+ .wm-path-trace {
1851
+ fill: none;
1852
+ stroke: var(--node-path);
1853
+ stroke-width: 1.2;
1854
+ stroke-linecap: round;
1855
+ stroke-linejoin: round;
1856
+ stroke-dasharray: 300;
1857
+ animation: wm-trace 6s ease-in-out infinite;
1858
+ }
1859
+
1860
+ .wm-trace-2 {
1861
+ animation-delay: -3s;
1862
+ stroke: var(--accent2);
1863
+ }
1864
+
1865
+ @keyframes wm-trace {
1866
+ 0% {
1867
+ stroke-dashoffset: 300;
1868
+ opacity: 0;
1869
+ }
1870
+
1871
+ 8% {
1872
+ opacity: 0.7;
1873
+ }
1874
+
1875
+ 50% {
1876
+ stroke-dashoffset: 0;
1877
+ opacity: 0.7;
1878
+ }
1879
+
1880
+ 85% {
1881
+ stroke-dashoffset: 0;
1882
+ opacity: 0;
1883
+ }
1884
+
1885
+ 100% {
1886
+ stroke-dashoffset: -300;
1887
+ opacity: 0;
1888
+ }
1889
+ }
1890
+
1891
+ .wm-particle {
1892
+ fill: var(--accent2);
1893
+ opacity: 0;
1894
+ }
1895
+
1896
+ .wm-p1 {
1897
+ animation: wm-float-1 6s linear infinite;
1898
+ }
1899
+
1900
+ .wm-p2 {
1901
+ animation: wm-float-2 8s linear infinite 1s;
1902
+ }
1903
+
1904
+ .wm-p3 {
1905
+ animation: wm-float-3 7s linear infinite 2.5s;
1906
+ }
1907
+
1908
+ .wm-p4 {
1909
+ animation: wm-float-4 9s linear infinite 4s;
1910
+ }
1911
+
1912
+ @keyframes wm-float-1 {
1913
+ 0% {
1914
+ cx: 42;
1915
+ cy: 52;
1916
+ opacity: 0;
1917
+ }
1918
+
1919
+ 10% {
1920
+ opacity: 0.7;
1921
+ }
1922
+
1923
+ 33% {
1924
+ cx: 100;
1925
+ cy: 100;
1926
+ }
1927
+
1928
+ 66% {
1929
+ cx: 158;
1930
+ cy: 52;
1931
+ }
1932
+
1933
+ 90% {
1934
+ opacity: 0.7;
1935
+ }
1936
+
1937
+ 100% {
1938
+ cx: 172;
1939
+ cy: 108;
1940
+ opacity: 0;
1941
+ }
1942
+ }
1943
+
1944
+ @keyframes wm-float-2 {
1945
+ 0% {
1946
+ cx: 100;
1947
+ cy: 28;
1948
+ opacity: 0;
1949
+ }
1950
+
1951
+ 10% {
1952
+ opacity: 0.5;
1953
+ }
1954
+
1955
+ 50% {
1956
+ cx: 100;
1957
+ cy: 100;
1958
+ }
1959
+
1960
+ 90% {
1961
+ opacity: 0.5;
1962
+ }
1963
+
1964
+ 100% {
1965
+ cx: 58;
1966
+ cy: 158;
1967
+ opacity: 0;
1968
+ }
1969
+ }
1970
+
1971
+ @keyframes wm-float-3 {
1972
+ 0% {
1973
+ cx: 172;
1974
+ cy: 108;
1975
+ opacity: 0;
1976
+ }
1977
+
1978
+ 10% {
1979
+ opacity: 0.6;
1980
+ }
1981
+
1982
+ 50% {
1983
+ cx: 100;
1984
+ cy: 100;
1985
+ }
1986
+
1987
+ 90% {
1988
+ opacity: 0.6;
1989
+ }
1990
+
1991
+ 100% {
1992
+ cx: 28;
1993
+ cy: 108;
1994
+ opacity: 0;
1995
+ }
1996
+ }
1997
+
1998
+ @keyframes wm-float-4 {
1999
+ 0% {
2000
+ cx: 58;
2001
+ cy: 158;
2002
+ opacity: 0;
2003
+ }
2004
+
2005
+ 10% {
2006
+ opacity: 0.4;
2007
+ }
2008
+
2009
+ 33% {
2010
+ cx: 100;
2011
+ cy: 100;
2012
+ }
2013
+
2014
+ 66% {
2015
+ cx: 100;
2016
+ cy: 28;
2017
+ }
2018
+
2019
+ 90% {
2020
+ opacity: 0.4;
2021
+ }
2022
+
2023
+ 100% {
2024
+ cx: 158;
2025
+ cy: 52;
2026
+ opacity: 0;
2027
+ }
2028
+ }
2029
+
2030
+ /* ══════════════════════════════════════════════════════════
2031
+ RESPONSIVE — TV · desktop · tablet · phone
2032
+ The base styles above are tuned for a ~1280–1680px desktop.
2033
+ Everything below adapts that single layout to other form
2034
+ factors. Guiding rule for small screens (per product intent):
2035
+ it's fine if the graph and side panel aren't visible at the
2036
+ SAME time on a phone — what matters is that every device can
2037
+ open the app and clearly see the logo, run a search, and read
2038
+ the resulting path (the success modal + the Path tab both show
2039
+ the full path, so the path is legible even when the graph
2040
+ itself is small).
2041
+ ══════════════════════════════════════════════════════════ */
2042
+
2043
+ /* ─── Large displays / TVs (1080p+ , couch viewing distance) ───
2044
+ Bump the things that are otherwise too small to read from across
2045
+ a room: the side panel, its text, status line, and graph labels. */
2046
+ @media (min-width: 1800px) {
2047
+ #panel { width: 380px; }
2048
+ .tab { font-size: 12px; padding: 16px 0; }
2049
+ .score-card .title { font-size: 14px; }
2050
+ .score-row { font-size: 13px; }
2051
+ .log-entry { font-size: 13px; }
2052
+ .legend-row { font-size: 14px; }
2053
+ #status-bar { font-size: 13px; }
2054
+ .sstat, .sstat-val { font-size: 13px; }
2055
+ .hop-btn-link { font-size: 13px; }
2056
+ #hero-title { font-size: 84px; }
2057
+ #hero-sub { font-size: 16px; max-width: 520px; }
2058
+ .node-label { font-size: 13px; }
2059
+ .node-label.centre-label { font-size: 15px; }
2060
+ }
2061
+
2062
+ /* ─── Tablets (landscape + large portrait) ─── */
2063
+ @media (max-width: 1024px) {
2064
+ #panel { width: 264px; }
2065
+ #hero-title { font-size: 54px; letter-spacing: 8px; }
2066
+ #hero-inputs { max-width: 680px; }
2067
+ .modal-card { padding: 32px; }
2068
+ .about-card { padding: 36px 36px; }
2069
+ }
2070
+
2071
+ /* ─── Stacked layout: graph on top, panel below ───
2072
+ Kicks in for narrow tablets / large phones. The single biggest
2073
+ change — the desktop side-by-side (#main is a flex ROW) becomes
2074
+ a vertical stack so neither the graph nor the panel is crushed
2075
+ into a sliver. */
2076
+ @media (max-width: 820px) {
2077
+ #main {
2078
+ flex-direction: column;
2079
+ }
2080
+
2081
+ #canvas-wrap {
2082
+ flex: 1 1 0;
2083
+ min-height: 200px;
2084
+ }
2085
+
2086
+ #panel {
2087
+ width: 100%;
2088
+ flex: 1 1 0;
2089
+ min-height: 180px;
2090
+ border-left: none;
2091
+ border-top: 1px solid var(--border);
2092
+ }
2093
+
2094
+ /* Topbar wraps instead of overflowing horizontally. The two
2095
+ article inputs share the first row; buttons + status flow
2096
+ onto following rows as needed. */
2097
+ #topbar {
2098
+ flex-wrap: wrap;
2099
+ height: auto;
2100
+ min-height: 52px;
2101
+ padding: 8px 12px;
2102
+ row-gap: 8px;
2103
+ }
2104
+
2105
+ .input-wrap {
2106
+ flex: 1 1 140px;
2107
+ min-width: 0;
2108
+ max-width: none;
2109
+ }
2110
+
2111
+ #status-bar {
2112
+ flex: 1 1 100%;
2113
+ order: 10;
2114
+ white-space: normal;
2115
+ }
2116
+ }
2117
+
2118
+ /* ─── Mobile collapse/expand: topbar + panel ───
2119
+ On phones/narrow tablets, the search-controls topbar and the path/
2120
+ scores/log panel default to collapsed right when the hero is dismissed
2121
+ (see app.js dismissHero()), so the first thing a mobile visitor sees is
2122
+ the live graph, not a wall of chrome. Each section's handle bar stays
2123
+ OUTSIDE the collapsing element (siblings in index.html) specifically so
2124
+ it's always visible/tappable even while its target is collapsed to zero
2125
+ height — collapsing the handle along with its target would make it
2126
+ impossible to reopen.
2127
+ Both handles are display:none above this breakpoint — on desktop/tablet
2128
+ the topbar and panel are simply always visible, full stop, and these
2129
+ elements have zero effect on that layout. */
2130
+ .mobile-handle {
2131
+ display: none;
2132
+ }
2133
+
2134
+ @media (max-width: 820px) {
2135
+ .mobile-handle {
2136
+ display: flex;
2137
+ align-items: center;
2138
+ justify-content: center;
2139
+ gap: 6px;
2140
+ height: 24px;
2141
+ flex-shrink: 0;
2142
+ background: var(--bg2);
2143
+ border-bottom: 1px solid var(--border);
2144
+ font-size: 10px;
2145
+ font-weight: 600;
2146
+ letter-spacing: 0.6px;
2147
+ text-transform: uppercase;
2148
+ color: var(--text2);
2149
+ cursor: pointer;
2150
+ transition: background 0.15s, color 0.15s;
2151
+ }
2152
+
2153
+ .mobile-handle:hover,
2154
+ .mobile-handle:active {
2155
+ color: var(--text);
2156
+ background: var(--bg3);
2157
+ }
2158
+
2159
+ .mobile-handle svg {
2160
+ transition: transform 0.25s ease;
2161
+ flex-shrink: 0;
2162
+ }
2163
+
2164
+ /* Chevron points down (expand me) when the target is collapsed, up
2165
+ (collapse me) when it's open — toggled by app.js alongside the
2166
+ target's own .mobile-collapsed class. */
2167
+ .mobile-handle.is-collapsed svg {
2168
+ transform: rotate(-90deg);
2169
+ }
2170
+
2171
+ #mobile-panel-handle {
2172
+ border-top: 1px solid var(--border);
2173
+ border-bottom: none;
2174
+ }
2175
+
2176
+ /* #topbar: animate via max-height (height:auto can't be transitioned
2177
+ directly) — generous enough to fit the wrapped multi-row mobile
2178
+ topbar without clipping while expanded. */
2179
+ #topbar {
2180
+ max-height: 300px;
2181
+ overflow: hidden;
2182
+ transition: max-height 0.32s cubic-bezier(0.16, 1, 0.3, 1),
2183
+ opacity 0.25s ease, padding 0.32s ease;
2184
+ }
2185
+
2186
+ #topbar.mobile-collapsed {
2187
+ max-height: 0;
2188
+ min-height: 0;
2189
+ opacity: 0;
2190
+ padding-top: 0;
2191
+ padding-bottom: 0;
2192
+ border-bottom: none;
2193
+ pointer-events: none;
2194
+ }
2195
+
2196
+ /* #panel: min-height (not max-height) is what's actually holding the
2197
+ stacked layout open at 180px — flex-grow:1 with min-height:0 here
2198
+ collapses cleanly to nothing, and #canvas-wrap's own flex-grow:1
2199
+ smoothly reclaims the freed space as this animates, growing the
2200
+ graph to fill the screen exactly as it does when the panel is
2201
+ genuinely empty. */
2202
+ #panel {
2203
+ transition: min-height 0.32s cubic-bezier(0.16, 1, 0.3, 1),
2204
+ opacity 0.25s ease;
2205
+ }
2206
+
2207
+ #panel.mobile-collapsed {
2208
+ flex-grow: 0;
2209
+ min-height: 0;
2210
+ opacity: 0;
2211
+ overflow: hidden;
2212
+ pointer-events: none;
2213
+ }
2214
+ }
2215
+
2216
+ /* ─── Phones ─── */
2217
+ @media (max-width: 560px) {
2218
+ /* Hero / landing */
2219
+ #hero-title {
2220
+ font-size: 38px;
2221
+ letter-spacing: 4px;
2222
+ margin-bottom: 10px;
2223
+ }
2224
+
2225
+ #hero-tagline {
2226
+ font-size: 9px;
2227
+ letter-spacing: 3px;
2228
+ margin-bottom: 30px;
2229
+ }
2230
+
2231
+ #hero-sub {
2232
+ font-size: 13px;
2233
+ line-height: 1.7;
2234
+ margin-bottom: 28px;
2235
+ padding: 0 6px;
2236
+ }
2237
+
2238
+ /* Inputs stack vertically; the arrow turns to point downward
2239
+ and loses the desktop top-margin that aligned it beside the
2240
+ labelled fields. align-items:stretch overrides the desktop
2241
+ flex-start — without it, each .hero-field shrinks to its own
2242
+ content width instead of spanning the column, so the FROM/TO
2243
+ boxes end up narrower than the container and visibly off-centre
2244
+ instead of forming one centred block. */
2245
+ #hero-inputs {
2246
+ flex-direction: column;
2247
+ align-items: stretch;
2248
+ gap: 8px;
2249
+ padding: 0 22px;
2250
+ max-width: 420px;
2251
+ }
2252
+
2253
+ .hero-field {
2254
+ width: 100%;
2255
+ }
2256
+
2257
+ .hero-field label {
2258
+ text-align: center;
2259
+ }
2260
+
2261
+ #hero-arrow {
2262
+ margin: 2px 0 0;
2263
+ align-self: center;
2264
+ transform: rotate(90deg);
2265
+ }
2266
+
2267
+ #hero-about-btn {
2268
+ top: 12px;
2269
+ right: 12px;
2270
+ padding: 6px 13px;
2271
+ font-size: 11px;
2272
+ }
2273
+
2274
+ #hero-btn {
2275
+ padding: 13px 44px;
2276
+ }
2277
+
2278
+ /* Topbar logo wordmark is redundant next to the mark on a phone
2279
+ — keep the icon, drop the text to save a lot of horizontal room. */
2280
+ #topbar h1 {
2281
+ font-size: 0;
2282
+ }
2283
+
2284
+ #topbar h1 .topbar-logo-img {
2285
+ height: 24px;
2286
+ width: 24px;
2287
+ }
2288
+
2289
+ /* Modals */
2290
+ .modal-card {
2291
+ padding: 24px 18px;
2292
+ width: 94%;
2293
+ max-height: 90vh;
2294
+ overflow-y: auto;
2295
+ }
2296
+
2297
+ .about-card {
2298
+ padding: 26px 20px;
2299
+ }
2300
+
2301
+ .modal-title {
2302
+ font-size: 21px;
2303
+ }
2304
+
2305
+ .modal-subtitle {
2306
+ font-size: 13px;
2307
+ margin-bottom: 22px;
2308
+ }
2309
+
2310
+ .modal-stat-row {
2311
+ gap: 22px;
2312
+ margin: 14px 0 20px;
2313
+ }
2314
+
2315
+ .modal-stat-val {
2316
+ font-size: 26px;
2317
+ }
2318
+
2319
+ .modal-path {
2320
+ padding: 14px 14px;
2321
+ gap: 6px;
2322
+ }
2323
+
2324
+ /* About modal: tech cards go single-column; the comparison
2325
+ table stays two columns but with tighter type so it still fits. */
2326
+ .about-tech-grid {
2327
+ grid-template-columns: 1fr;
2328
+ }
2329
+
2330
+ .about-compare-cell {
2331
+ font-size: 11px;
2332
+ padding: 8px 10px;
2333
+ }
2334
+
2335
+ .about-compare-head {
2336
+ font-size: 8px;
2337
+ letter-spacing: 1.8px;
2338
+ padding: 8px 10px;
2339
+ }
2340
+
2341
+ .about-quote-text {
2342
+ font-size: 14px;
2343
+ }
2344
+
2345
+ .onboarding-card {
2346
+ padding: 32px 24px 28px;
2347
+ }
2348
+ }
2349
+
2350
+ /* ─── Short / landscape phones: prefer dynamic viewport height so
2351
+ mobile browser chrome (URL bar) doesn't clip the bottom of the
2352
+ app. 100dvh is ignored by browsers that don't support it, falling
2353
+ back to the 100vh set on body above. ─── */
2354
+ @media (max-width: 820px) {
2355
+ body {
2356
+ height: 100dvh;
2357
+ }
2358
+ }
wiki.py ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Aurelius — Wikipedia API client: resolve, links, backlinks, summary,
3
+ categories, and junk/date filters.
4
+
5
+ Module split per architect plan §1. This is a literal relocation of the
6
+ Wikipedia-facing functions previously in main.py (lines 99-103, 190-394,
7
+ 397-402 in the pre-split file) — no behavioral changes.
8
+ """
9
+
10
+ import asyncio
11
+ import re
12
+ from typing import Optional
13
+ from urllib.parse import unquote
14
+
15
+ import httpx
16
+
17
+ from config import WIKI_API, WIKI_HEADERS, MAX_FETCH, MAX_PAGES, SEED_FILE
18
+
19
+
20
+ def make_wiki_client() -> httpx.AsyncClient:
21
+ return httpx.AsyncClient(headers=WIKI_HEADERS, follow_redirects=True,
22
+ timeout=httpx.Timeout(25.0))
23
+
24
+
25
+ async def wiki_get(client: httpx.AsyncClient, params: dict, retries: int = 3) -> dict:
26
+ for attempt in range(retries + 1):
27
+ try:
28
+ r = await client.get(WIKI_API, params=params)
29
+ if r.status_code == 429:
30
+ await asyncio.sleep(min(2 ** attempt, 8)); continue
31
+ if r.status_code != 200:
32
+ return {}
33
+ return r.json()
34
+ except httpx.TimeoutException:
35
+ if attempt < retries: await asyncio.sleep(1)
36
+ except Exception as e:
37
+ print(f"[wiki] {e}"); return {}
38
+ return {}
39
+
40
+
41
+ def extract_title_from_url(query: str) -> Optional[str]:
42
+ """If query is a Wikipedia URL, extract and decode the article title; else None."""
43
+ m = re.search(r'wikipedia\.org/wiki/([^#?\s]+)', query.strip())
44
+ if not m:
45
+ return None
46
+ return unquote(m.group(1)).replace('_', ' ').strip()
47
+
48
+
49
+ async def resolve_article_title(client: httpx.AsyncClient, query: str) -> Optional[str]:
50
+ query = query.strip()
51
+ url_t = extract_title_from_url(query)
52
+ if url_t: query = url_t
53
+
54
+ data = await wiki_get(client, {"action":"query","titles":query,
55
+ "redirects":1,"format":"json","utf8":1})
56
+ for pid, page in data.get("query",{}).get("pages",{}).items():
57
+ if pid != "-1":
58
+ print(f"[resolve] Exact ✓ '{page['title']}'")
59
+ return page["title"]
60
+
61
+ data = await wiki_get(client, {"action":"opensearch","search":query,
62
+ "limit":10,"namespace":0,"format":"json","utf8":1})
63
+ titles = data[1] if isinstance(data, list) and len(data) > 1 else []
64
+ if titles:
65
+ for t in titles:
66
+ if t.lower() == query.lower(): return t
67
+ return titles[0]
68
+
69
+ data = await wiki_get(client, {"action":"query","list":"search","srsearch":query,
70
+ "srlimit":5,"srprop":"title","format":"json","utf8":1})
71
+ results = data.get("query",{}).get("search",[])
72
+ if results:
73
+ return results[0]["title"]
74
+ return None
75
+
76
+
77
+ _DATE_RE = re.compile(
78
+ r'^\d{1,2} \w+$|^\w+ \d{1,2}$|^\d{4}$|^\d{4} in '
79
+ r'|^\d{1,2}\w* century'
80
+ r'|^(January|February|March|April|May|June|July|August|'
81
+ r'September|October|November|December)$'
82
+ r'|^\d{4}[-–]\d{2,4}$', re.IGNORECASE
83
+ )
84
+ def _is_date(t: str) -> bool:
85
+ return bool(_DATE_RE.search(t))
86
+
87
+ _JUNK_TITLES = frozenset({
88
+ "Wayback Machine", "OCLC", "Digital object identifier", "CrossRef",
89
+ "JSTOR", "PubMed", "PubMed Central", "Semantic Scholar", "ArXiv",
90
+ "Bibcode", "CiteSeerX", "S2CID", "Zbl", "MR", "PMC",
91
+ })
92
+ def _is_junk(t: str) -> bool:
93
+ """Bibliographic citation artifacts: dead ends with 1-3 links each."""
94
+ return t.endswith("(identifier)") or t in _JUNK_TITLES
95
+
96
+ async def get_all_links(client: httpx.AsyncClient, title: str,
97
+ end_title: str = "",
98
+ target_neighbours: set | None = None
99
+ ) -> tuple[list[str], dict[str, str]]:
100
+ """
101
+ Fetch ALL links via pagination (up to MAX_FETCH), redirect-resolved to
102
+ canonical titles. Returns (links, short_descriptions).
103
+
104
+ Uses generator=links (not prop=links) + redirects=1 so that a link
105
+ written as e.g. "A* algorithm" in the wikitext comes back as its
106
+ canonical target "A* search algorithm" — MediaWiki's `redirects`
107
+ parameter resolves redirects in generator output, not just in the
108
+ `titles` input. Without this, the search graph carries raw,
109
+ possibly-redirect link titles, which silently breaks every canonical-
110
+ title comparison downstream (direct-hit check, the target-frontier
111
+ meeting check, de-duplication) — e.g. a redirect that happens to read
112
+ as semantically close to the target can look like a near-miss "hit"
113
+ when it never actually appears in the target's real backlink set.
114
+
115
+ Also requests prop=pageprops with ppprop=wikibase-shortdesc in the SAME
116
+ call (no extra round-trip) — Wikidata's one-line human-curated short
117
+ description (e.g. "Mesopotamian sky-god" for Anu). Bare-title embeddings
118
+ can't distinguish a proper noun's domain, which let semantically
119
+ unrelated but link-adjacent pages (Egyptian/Mesopotamian deities,
120
+ surahs) survive the prune floor on noisy title-only cosine. Embedding
121
+ "{title}. {short_desc}" instead gives the model the actual subject
122
+ matter to score against — see search.py's _rank_candidates.
123
+
124
+ Goal link is always included first if found.
125
+ Target neighbours always get priority slots.
126
+ Date/year stubs filtered unless list would be empty.
127
+
128
+ BUGFIX: when hunting for end_title, the old exit condition
129
+ (len(all_links) >= MAX_FETCH) stopped pagination after roughly the
130
+ first alphabetical page of a large article, so a goal link sorting
131
+ late in the alphabet (e.g. "Pakistan" on India's 1000+-link page)
132
+ was never found even though it's a direct 1-hop link. We now keep
133
+ paginating past MAX_FETCH while specifically searching for a goal,
134
+ bounded by MAX_PAGES as a safety cap. Calls with no end_title (e.g.
135
+ pre-seeding target neighbours) are unaffected — same stop condition
136
+ as before.
137
+ """
138
+ all_links: list[str] = []
139
+ date_links: list[str] = []
140
+ goal_link: str | None = None
141
+ short_desc: dict[str, str] = {}
142
+ end_lower = end_title.lower() if end_title else ""
143
+ params = {
144
+ "action": "query", "generator": "links", "redirects": 1,
145
+ "titles": title, "prop": "info|pageprops", "ppprop": "wikibase-shortdesc",
146
+ "gpllimit": 500, "gplnamespace": 0, "format": "json", "utf8": 1,
147
+ }
148
+ pages_fetched = 0
149
+ while True:
150
+ data = await wiki_get(client, params)
151
+ pages_fetched += 1
152
+ for page in data.get("query", {}).get("pages", {}).values():
153
+ if "missing" in page:
154
+ continue # link target doesn't exist (red link)
155
+ t = page["title"]
156
+ sd = page.get("pageprops", {}).get("wikibase-shortdesc")
157
+ if sd:
158
+ short_desc[t] = sd
159
+ if end_lower and t.lower() == end_lower:
160
+ goal_link = t; continue
161
+ if _is_junk(t):
162
+ continue
163
+ if _is_date(t):
164
+ date_links.append(t)
165
+ else:
166
+ all_links.append(t)
167
+ cont = data.get("continue", {})
168
+ if "gplcontinue" not in cont:
169
+ break
170
+ if goal_link:
171
+ break # found it — no need to keep paginating
172
+ if end_lower:
173
+ # Actively hunting a specific goal: keep going past MAX_FETCH,
174
+ # bounded only by MAX_PAGES (safety cap on API calls).
175
+ if pages_fetched >= MAX_PAGES:
176
+ break
177
+ else:
178
+ # No specific goal (e.g. pre-seeding neighbours): original behavior.
179
+ if len(all_links) + len(date_links) >= MAX_FETCH:
180
+ break
181
+ params["gplcontinue"] = cont["gplcontinue"]
182
+
183
+ result: list[str] = []
184
+ if goal_link:
185
+ result.append(goal_link)
186
+
187
+ tn = target_neighbours or set()
188
+ bridges = [l for l in all_links if l in tn]
189
+ rest = [l for l in all_links if l not in tn]
190
+ result += bridges
191
+ result += rest
192
+ if len(result) < 5:
193
+ result += date_links[:max(0, 5 - len(result))]
194
+
195
+ return result[:MAX_FETCH], short_desc
196
+
197
+ async def get_backlinks(client: httpx.AsyncClient, title: str,
198
+ limit: int = MAX_FETCH) -> list[str]:
199
+ """
200
+ Fetch articles that link TO `title` (Wikipedia prop=linkshere) — the
201
+ correct direction for a forward-search bridge set.
202
+
203
+ get_all_links() returns what `title` points to. That is NOT useful as
204
+ a "one hop from target" bridge set, because Wikipedia links are not
205
+ reciprocal: target -> A does not imply A -> target. A backlink (B ->
206
+ target) is a guaranteed bridge — if the forward search ever reaches B,
207
+ expanding it is certain to find `target` in B's own outbound links.
208
+ Using outbound links here was sending the search on real-but-useless
209
+ detours (e.g. the "Star Wars" article links to "Star Destroyer", which
210
+ in turn links to real-world "Capital ship"/"Destroyer" articles for
211
+ etymology — those score high on the old outbound-based frontier but
212
+ don't link back to anything Star Wars-related themselves).
213
+ """
214
+ all_links: list[str] = []
215
+ date_links: list[str] = []
216
+ params = {
217
+ "action": "query", "titles": title, "prop": "linkshere",
218
+ "lhlimit": 500, "lhnamespace": 0, "format": "json", "utf8": 1,
219
+ }
220
+ pages_fetched = 0
221
+ while True:
222
+ data = await wiki_get(client, params)
223
+ pages_fetched += 1
224
+ for page in data.get("query", {}).get("pages", {}).values():
225
+ for link in page.get("linkshere", []):
226
+ t = link["title"]
227
+ if _is_junk(t):
228
+ continue
229
+ if _is_date(t):
230
+ date_links.append(t)
231
+ else:
232
+ all_links.append(t)
233
+ cont = data.get("continue", {})
234
+ if "lhcontinue" not in cont:
235
+ break
236
+ if len(all_links) + len(date_links) >= limit or pages_fetched >= MAX_PAGES:
237
+ break
238
+ params["lhcontinue"] = cont["lhcontinue"]
239
+ return (all_links + date_links)[:limit]
240
+
241
+ async def check_disambiguation(client: httpx.AsyncClient, title: str) -> bool:
242
+ data = await wiki_get(client, {
243
+ "action": "query", "titles": title,
244
+ "prop": "pageprops", "ppprop": "disambiguation",
245
+ "format": "json", "utf8": 1,
246
+ })
247
+ for page in data.get("query", {}).get("pages", {}).values():
248
+ if "disambiguation" in page.get("pageprops", {}):
249
+ return True
250
+ return False
251
+
252
+
253
+ async def get_link_display_text(client: httpx.AsyncClient, source_title: str,
254
+ target_title: str) -> Optional[str]:
255
+ """
256
+ Best-effort lookup of the visible text a wikilink uses on `source_title`
257
+ when it points to `target_title`, e.g. `[[Amber Road|routes]]` -> "routes".
258
+
259
+ Exists because get_all_links() (and therefore every edge in the search
260
+ graph) is built from MediaWiki's `links` API, which returns the
261
+ canonical link TARGET, never the piped display text. That's correct for
262
+ graph traversal, but it means a perfectly real edge can look fabricated
263
+ to a human skimming the rendered page for the target's title (e.g.
264
+ "Western world" -> "Amber Road": the link is real, but the article
265
+ displays it as "routes", so Ctrl+F for "Amber Road" finds nothing). See
266
+ CLAUDE.md's "Semantic drift" history entry for the earlier instance of
267
+ this same confusion ('Anu' -> 'Iteration').
268
+
269
+ Returns None if the link isn't piped, the display text matches the
270
+ title anyway, or the link couldn't be located in raw wikitext (e.g. it
271
+ comes from a transcluded template) — callers should treat None as
272
+ "nothing extra to show," not an error.
273
+ """
274
+ data = await wiki_get(client, {
275
+ "action": "parse", "page": source_title, "prop": "wikitext",
276
+ "format": "json", "utf8": 1,
277
+ })
278
+ wikitext = data.get("parse", {}).get("wikitext", {}).get("*", "")
279
+ if not wikitext:
280
+ return None
281
+
282
+ # Build the pattern from escaped words joined by a flexible separator —
283
+ # escaping the title whole and then substituting spaces for [ _]+
284
+ # doesn't work because re.escape() itself backslash-escapes the space,
285
+ # leaving a stray "\" that turns the substituted "[ _]+" into a literal
286
+ # bracket. Escaping word-by-word and joining avoids that entirely.
287
+ words = re.split(r"[ _]+", target_title)
288
+ target_pattern = r"[ _]+".join(re.escape(w) for w in words)
289
+ regex = re.compile(r"\[\[\s*" + target_pattern + r"\s*(?:\|([^\]]+))?\]\]", re.IGNORECASE)
290
+ match = regex.search(wikitext)
291
+ if not match or not match.group(1):
292
+ return None
293
+
294
+ display = re.sub(r"'{2,}", "", match.group(1)).strip()
295
+ if not display or display.lower().replace("_", " ") == target_title.lower().replace("_", " "):
296
+ return None
297
+ return display
298
+
299
+
300
+ async def get_article_summary(client: httpx.AsyncClient, title: str) -> str:
301
+ data = await wiki_get(client, {"action":"query","titles":title,"prop":"extracts",
302
+ "exintro":True,"explaintext":True,
303
+ "exsentences":2,"format":"json","utf8":1})
304
+ for page in data.get("query",{}).get("pages",{}).values():
305
+ return page.get("extract","")[:220]
306
+ return ""
307
+
308
+ def _load_seed_titles() -> list[str]:
309
+ """Read the curated topic list used by the Random button, one title per line."""
310
+ if not SEED_FILE.exists():
311
+ return []
312
+ with open(SEED_FILE, "r", encoding="utf-8") as f:
313
+ return [line.strip() for line in f if line.strip()]