murtaza-2007 commited on
Commit ·
2bd66df
0
Parent(s):
Initial commit
Browse files- .gitignore +5 -0
- CLAUDE.md +252 -0
- LOGO.png +0 -0
- LOGOnobg.png +0 -0
- app.js +1344 -0
- common_wiki_searches.txt +556 -0
- config.py +94 -0
- embedding.py +138 -0
- htmlbackup.txt +0 -0
- index.html +498 -0
- main.py +34 -0
- mainpybackup.txt +799 -0
- requirements.txt +5 -0
- search.py +569 -0
- server.py +126 -0
- styles.css +2358 -0
- 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, '"')}">
|
| 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 & 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()]
|