mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 07:30:13 +01:00
feat(web): Core Refactor Phase A — extract sampling and cache modules; add adaptive TTL + eviction heuristics, Redis PoC, and metrics wiring. Tests added for TTL, eviction, exports, splash-adaptive, card index, and service worker. Docs+roadmap updated.
This commit is contained in:
parent
c4a7fc48ea
commit
a029d430c5
49 changed files with 3889 additions and 701 deletions
63
.github/workflows/editorial_governance.yml
vendored
63
.github/workflows/editorial_governance.yml
vendored
|
|
@ -49,4 +49,65 @@ jobs:
|
|||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ratchet-proposal
|
||||
path: ratchet_proposal.json
|
||||
path: ratchet_proposal.json
|
||||
- name: Post ratchet proposal PR comment
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const markerStart = '<!-- ratchet-proposal:description-fallback -->';
|
||||
const markerEnd = '<!-- end-ratchet-proposal -->';
|
||||
let proposal = {};
|
||||
try { proposal = JSON.parse(fs.readFileSync('ratchet_proposal.json','utf8')); } catch(e) { proposal = {error: 'Failed to read ratchet_proposal.json'}; }
|
||||
function buildBody(p) {
|
||||
if (p.error) {
|
||||
return `${markerStart}\n**Description Fallback Ratchet Proposal**\n\n:warning: Could not compute proposal: ${p.error}. Ensure history file exists and job built with EDITORIAL_INCLUDE_FALLBACK_SUMMARY=1.\n${markerEnd}`;
|
||||
}
|
||||
const curTotal = p.current_total_ceiling;
|
||||
const curPct = p.current_pct_ceiling;
|
||||
const propTotal = p.proposed_total_ceiling;
|
||||
const propPct = p.proposed_pct_ceiling;
|
||||
const changedTotal = propTotal !== curTotal;
|
||||
const changedPct = propPct !== curPct;
|
||||
const rationale = (p.rationale && p.rationale.length) ? p.rationale.map(r=>`- ${r}`).join('\n') : '- No ratchet conditions met (headroom not significant).';
|
||||
const testFile = 'code/tests/test_theme_description_fallback_regression.py';
|
||||
let updateSnippet = 'No changes recommended.';
|
||||
if (changedTotal || changedPct) {
|
||||
updateSnippet = [
|
||||
'Update ceilings in regression test (lines asserting generic_total & generic_pct):',
|
||||
'```diff',
|
||||
`- assert summary.get('generic_total', 0) <= ${curTotal}, summary`,
|
||||
`+ assert summary.get('generic_total', 0) <= ${propTotal}, summary`,
|
||||
`- assert summary.get('generic_pct', 100.0) < ${curPct}, summary`,
|
||||
`+ assert summary.get('generic_pct', 100.0) < ${propPct}, summary`,
|
||||
'```' ].join('\n');
|
||||
}
|
||||
return `${markerStart}\n**Description Fallback Ratchet Proposal**\n\nLatest snapshot generic_total: **${p.latest_total}** | median recent generic_pct: **${p.median_recent_pct}%** (window ${p.records_considered})\n\n| Ceiling | Current | Proposed |\n|---------|---------|----------|\n| generic_total | ${curTotal} | ${propTotal}${changedTotal ? ' ←' : ''} |\n| generic_pct | ${curPct}% | ${propPct}%${changedPct ? ' ←' : ''} |\n\n**Rationale**\n${rationale}\n\n${updateSnippet}\n\nHistory-based ratcheting keeps pressure on reducing generic fallback descriptions. If adopting the new ceilings, ensure editorial quality remains stable.\n\n_Analysis generated by ratchet bot._\n${markerEnd}`;
|
||||
}
|
||||
const body = buildBody(proposal);
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
per_page: 100
|
||||
});
|
||||
const existing = comments.find(c => c.body && c.body.includes(markerStart));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body
|
||||
});
|
||||
core.info('Updated existing ratchet proposal comment.');
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body
|
||||
});
|
||||
core.info('Created new ratchet proposal comment.');
|
||||
}
|
||||
49
.github/workflows/preview-perf-ci.yml
vendored
Normal file
49
.github/workflows/preview-perf-ci.yml
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
name: Preview Performance Regression Gate
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'code/**'
|
||||
- 'csv_files/**'
|
||||
- 'logs/perf/theme_preview_warm_baseline.json'
|
||||
- '.github/workflows/preview-perf-ci.yml'
|
||||
|
||||
jobs:
|
||||
preview-perf:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
PYTHONUNBUFFERED: '1'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
- name: Launch app (background)
|
||||
run: |
|
||||
python -m uvicorn code.web.app:app --host 0.0.0.0 --port 8080 &
|
||||
echo $! > uvicorn.pid
|
||||
# simple wait
|
||||
sleep 5
|
||||
- name: Run preview performance CI check
|
||||
run: |
|
||||
python -m code.scripts.preview_perf_ci_check --url http://localhost:8080 --baseline logs/perf/theme_preview_warm_baseline.json --p95-threshold 5
|
||||
- name: Upload candidate artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: preview-perf-candidate
|
||||
path: logs/perf/theme_preview_ci_candidate.json
|
||||
- name: Stop app
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f uvicorn.pid ]; then kill $(cat uvicorn.pid) || true; fi
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -14,6 +14,13 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- Taxonomy snapshot CLI (`code/scripts/snapshot_taxonomy.py`): writes an auditable JSON snapshot of BRACKET_DEFINITIONS to `logs/taxonomy_snapshots/` with a deterministic SHA-256 hash; skips duplicates unless forced.
|
||||
- Optional adaptive splash penalty (feature flag): enable with `SPLASH_ADAPTIVE=1`; tuning via `SPLASH_ADAPTIVE_SCALE` (default `1:1.0,2:1.0,3:1.0,4:0.6,5:0.35`).
|
||||
- Splash penalty analytics: counters now include total off-color cards and penalty reason events; structured logs include event details to support tuning.
|
||||
- Tests: color identity edge cases (hybrid, colorless/devoid, MDFC single, adventure, color indicator) using synthetic CSV injection via `CARD_INDEX_EXTRA_CSV`.
|
||||
- Core Refactor Phase A (initial): extracted sampling pipeline (`sampling.py`) and preview cache container (`preview_cache.py`) from `theme_preview.py` with stable public API re-exports.
|
||||
- Adaptive preview cache eviction heuristic replacing FIFO with env-tunable weights (`THEME_PREVIEW_EVICT_W_HITS`, `_W_RECENCY`, `_W_COST`, `_W_AGE`) and cost thresholds (`THEME_PREVIEW_EVICT_COST_THRESHOLDS`); metrics include eviction counters and last event metadata.
|
||||
- Performance CI gate: warm-only p95 regression threshold (default 5%) enforced via `preview_perf_ci_check.py`; baseline refresh policy documented.
|
||||
- ETag header for basic client-side caching of catalog fragments.
|
||||
- Theme catalog performance optimizations: precomputed summary maps, lowercase search haystacks, memoized filtered slug cache (keyed by `(etag, params)`) for sub‑50ms warm queries.
|
||||
- Theme preview endpoint: `GET /themes/api/theme/{id}/preview` (and HTML fragment) returning representative sample (curated examples, curated synergy examples, heuristic roles: payoff / enabler / support / wildcard / synthetic).
|
||||
|
|
@ -27,13 +34,22 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
- Optional filter cache prewarm (`WEB_THEME_FILTER_PREWARM=1`) priming common filter combinations; metrics include `filter_prewarmed`.
|
||||
- Preview modal UX: role chips, condensed reasons line, hover tooltip with multiline heuristic reasons, export bar (CSV/JSON) honoring curated-only toggle.
|
||||
- Server authoritative mana & color identity ingestion (exposes `mana_cost`, `color_identity_list`, `pip_colors`) replacing client-side parsing.
|
||||
- Adaptive preview cache eviction heuristic replacing FIFO: protection score combines log(hit_count), recency, build cost bucket, and age penalty with env-tunable weights (`THEME_PREVIEW_EVICT_W_HITS`, `_W_RECENCY`, `_W_COST`, `_W_AGE`) plus cost thresholds (`THEME_PREVIEW_EVICT_COST_THRESHOLDS`). Metrics now include total evictions, by-reason counts (`low_score`, `emergency_overflow`), and last eviction metadata.
|
||||
- Scryfall name normalization regression test (`test_scryfall_name_normalization.py`) ensuring synergy annotation suffix (` - Synergy (...)`) never leaks into fuzzy/image queries.
|
||||
- Optional multi-pass performance CI variant (`preview_perf_ci_check.py --multi-pass`) to collect cold vs warm pass stats when diagnosing divergence.
|
||||
|
||||
### Changed
|
||||
- Splash analytics recognize both static and adaptive penalty reasons (shared prefix handling), so existing dashboards continue to work when `SPLASH_ADAPTIVE=1`.
|
||||
- Picker list & API use optimized fast filtering path (`filter_slugs_fast`) replacing per-request linear scans.
|
||||
- Preview sampling: curated examples pinned first, diversity quotas (~40% payoff / 40% enabler+support / 20% wildcard), synthetic placeholders only if underfilled.
|
||||
- Sampling refinements: rarity diminishing weight, splash leniency (single off-color allowance with penalty for 4–5 color commanders), role saturation penalty, refined commander overlap scaling curve.
|
||||
- Hover / DFC UX unified: single hover panel, overlay flip control (keyboard + persisted face), enlarged thumbnails (110px→165px→230px), activation limited to thumbnails.
|
||||
- Removed legacy client-side mana & color identity parsers (now server authoritative fields included in preview items and export endpoints).
|
||||
- Core Refactor Phase A continued: separated sampling + cache container; card index & adaptive TTL/background refresh extraction planned (roadmap updated) to further reduce `theme_preview.py` responsibilities.
|
||||
- Eviction: removed hard 50-entry minimum to support low-limit unit tests; production should set `THEME_PREVIEW_CACHE_MAX` accordingly.
|
||||
- Governance: README governance appendix now documents taxonomy snapshot usage and rationale.
|
||||
- Removed hard minimum (50) floor in eviction capacity logic to allow low-limit unit tests; operational environments should set `THEME_PREVIEW_CACHE_MAX` appropriately.
|
||||
- Performance gating formalized: CI fails if warm p95 regression > configured threshold (default 5%). Baseline refresh policy: only update committed warm baseline when (a) intentional performance improvement >10% p95, or (b) unavoidable drift exceeds threshold and is justified in CHANGELOG entry.
|
||||
|
||||
### Fixed
|
||||
- Removed redundant template environment instantiation causing inconsistent navigation state.
|
||||
|
|
|
|||
11
DOCKER.md
11
DOCKER.md
|
|
@ -88,6 +88,7 @@ Docker Hub (PowerShell) example:
|
|||
docker run --rm `
|
||||
-p 8080:8080 `
|
||||
-e SHOW_LOGS=1 -e SHOW_DIAGNOSTICS=1 -e ENABLE_THEMES=1 -e THEME=system `
|
||||
-e SPLASH_ADAPTIVE=1 -e SPLASH_ADAPTIVE_SCALE="1:1.0,2:1.0,3:1.0,4:0.6,5:0.35" ` # optional experiment
|
||||
-e RANDOM_MODES=1 -e RANDOM_UI=1 -e RANDOM_MAX_ATTEMPTS=5 -e RANDOM_TIMEOUT_MS=5000 `
|
||||
-v "${PWD}/deck_files:/app/deck_files" `
|
||||
-v "${PWD}/logs:/app/logs" `
|
||||
|
|
@ -151,6 +152,16 @@ services:
|
|||
- CSV_FILES_DIR=/app/csv_files/testdata
|
||||
```
|
||||
|
||||
### Taxonomy snapshot (maintainers)
|
||||
Capture the current bracket taxonomy into an auditable JSON file inside the container:
|
||||
|
||||
```powershell
|
||||
docker compose run --rm web bash -lc "python -m code.scripts.snapshot_taxonomy"
|
||||
```
|
||||
Artifacts appear under `./logs/taxonomy_snapshots/` on your host via the mounted volume.
|
||||
|
||||
To force a new snapshot even when the content hash matches the latest, pass `--force` to the module.
|
||||
|
||||
## Volumes
|
||||
- `/app/deck_files` ↔ `./deck_files`
|
||||
- `/app/logs` ↔ `./logs`
|
||||
|
|
|
|||
BIN
README.md
BIN
README.md
Binary file not shown.
|
|
@ -3,6 +3,9 @@
|
|||
## Unreleased (Draft)
|
||||
|
||||
### Added
|
||||
- Taxonomy snapshot utility (`python -m code.scripts.snapshot_taxonomy`): captures an auditable JSON of BRACKET_DEFINITIONS under `logs/taxonomy_snapshots/` with a content hash. Safe to run any time; subsequent identical snapshots are skipped.
|
||||
- Optional adaptive splash penalty (experiment): enable with `SPLASH_ADAPTIVE=1`; scale per commander color count with `SPLASH_ADAPTIVE_SCALE` (default `1:1.0,2:1.0,3:1.0,4:0.6,5:0.35`). Reasons are emitted as `splash_off_color_penalty_adaptive:<colors>:<value>`.
|
||||
- Analytics: splash penalty counters recognize both static and adaptive reasons; compare deltas with the flag toggled.
|
||||
- Theme picker performance: precomputed summary projections + lowercase haystacks and memoized filtered slug cache (keyed by (etag, q, archetype, bucket, colors)) for sub‑50ms typical list queries on warm path.
|
||||
- Skeleton loading UI for theme picker list, preview modal, and initial shell.
|
||||
- Theme preview endpoint (`/themes/api/theme/{id}/preview` + HTML fragment) returning representative sample with roles (payoff/enabler/support/wildcard/example/curated_synergy/synthetic).
|
||||
|
|
@ -17,12 +20,14 @@
|
|||
- Server authoritative mana & color identity fields (`mana_cost`, `color_identity_list`, `pip_colors`) included in preview/export; legacy client parsers removed.
|
||||
|
||||
### Changed
|
||||
- Splash analytics updated to count both static and adaptive penalty reasons via a shared prefix, keeping historical dashboards intact.
|
||||
- Preview assembly now pins curated `example_cards` then `synergy_example_cards` before heuristic sampling with diversity quotas (~40% payoff, 40% enabler/support, 20% wildcard) and synthetic placeholders only when underfilled.
|
||||
- List & API filtering route migrated to optimized path avoiding repeated concatenation / casefolding work each request.
|
||||
- Hover system consolidated to one global panel; removed fragment-specific duplicate & legacy large-image hover. Thumbnails enlarged & unified (110px → 165px → 230px). Hover activation limited to thumbnails; stability improved (no dismissal over flip control); DFC markup simplified to single <img> with opacity transition.
|
||||
|
||||
### Deprecated
|
||||
- (None new)
|
||||
- Price / legality snippet integration deferred to Budget Mode. Any interim badges will be tracked under `logs/roadmaps/roadmap_9_budget_mode.md`.
|
||||
- Legacy client-side mana/color identity parsers are considered deprecated; server-authoritative fields are now included in preview/export payloads.
|
||||
|
||||
### Fixed
|
||||
- Resolved duplicate template environment instantiation causing inconsistent navigation globals in picker fragments.
|
||||
|
|
|
|||
5
_tmp_check_metrics.py
Normal file
5
_tmp_check_metrics.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import urllib.request, json
|
||||
raw = urllib.request.urlopen("http://localhost:8000/themes/metrics").read().decode()
|
||||
js=json.loads(raw)
|
||||
print('example_enforcement_active=', js.get('preview',{}).get('example_enforcement_active'))
|
||||
print('example_enforce_threshold_pct=', js.get('preview',{}).get('example_enforce_threshold_pct'))
|
||||
309
code/scripts/preview_perf_benchmark.py
Normal file
309
code/scripts/preview_perf_benchmark.py
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
"""Ad-hoc performance benchmark for theme preview build latency (Phase A validation).
|
||||
|
||||
Runs warm-up plus measured request loops against several theme slugs and prints
|
||||
aggregate latency stats (p50/p90/p95, cache hit ratio evolution). Intended to
|
||||
establish or validate that refactor did not introduce >5% p95 regression.
|
||||
|
||||
Usage (ensure server running locally – commonly :8080 in docker compose):
|
||||
python -m code.scripts.preview_perf_benchmark --themes 8 --loops 40 \
|
||||
--url http://localhost:8080 --warm 1 --limit 12
|
||||
|
||||
Theme slug discovery hierarchy (when --theme not provided):
|
||||
1. Try /themes/index.json (legacy / planned static index)
|
||||
2. Fallback to /themes/api/themes (current API) and take the first N ids
|
||||
The discovered slugs are sorted deterministically then truncated to N.
|
||||
|
||||
NOTE: This is intentionally minimal (no external deps). For stable comparisons
|
||||
run with identical parameters pre/post-change and commit the JSON output under
|
||||
logs/perf/.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import statistics
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _fetch_json(url: str) -> Dict[str, Any]:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp: # nosec B310 local dev
|
||||
data = resp.read().decode("utf-8", "replace")
|
||||
return json.loads(data) # type: ignore[return-value]
|
||||
|
||||
|
||||
def select_theme_slugs(base_url: str, count: int) -> List[str]:
|
||||
"""Discover theme slugs for benchmarking.
|
||||
|
||||
Attempts legacy static index first, then falls back to live API listing.
|
||||
"""
|
||||
errors: List[str] = []
|
||||
slugs: List[str] = []
|
||||
# Attempt 1: legacy /themes/index.json
|
||||
try:
|
||||
idx = _fetch_json(f"{base_url.rstrip('/')}/themes/index.json")
|
||||
entries = idx.get("themes") or []
|
||||
for it in entries:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
slug = it.get("slug") or it.get("id") or it.get("theme_id")
|
||||
if isinstance(slug, str):
|
||||
slugs.append(slug)
|
||||
except Exception as e: # pragma: no cover - network variability
|
||||
errors.append(f"index.json failed: {e}")
|
||||
|
||||
if not slugs:
|
||||
# Attempt 2: live API listing
|
||||
try:
|
||||
listing = _fetch_json(f"{base_url.rstrip('/')}/themes/api/themes")
|
||||
items = listing.get("items") or []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
tid = it.get("id") or it.get("slug") or it.get("theme_id")
|
||||
if isinstance(tid, str):
|
||||
slugs.append(tid)
|
||||
except Exception as e: # pragma: no cover - network variability
|
||||
errors.append(f"api/themes failed: {e}")
|
||||
|
||||
slugs = sorted(set(slugs))[:count]
|
||||
if not slugs:
|
||||
raise SystemExit("No theme slugs discovered; cannot benchmark (" + "; ".join(errors) + ")")
|
||||
return slugs
|
||||
|
||||
|
||||
def fetch_all_theme_slugs(base_url: str, page_limit: int = 200) -> List[str]:
|
||||
"""Fetch all theme slugs via paginated /themes/api/themes endpoint.
|
||||
|
||||
Uses maximum page size (200) and iterates using offset until no next page.
|
||||
Returns deterministic sorted unique list of slugs.
|
||||
"""
|
||||
slugs: List[str] = []
|
||||
offset = 0
|
||||
seen: set[str] = set()
|
||||
while True:
|
||||
try:
|
||||
url = f"{base_url.rstrip('/')}/themes/api/themes?limit={page_limit}&offset={offset}"
|
||||
data = _fetch_json(url)
|
||||
except Exception as e: # pragma: no cover - network variability
|
||||
raise SystemExit(f"Failed fetching themes page offset={offset}: {e}")
|
||||
items = data.get("items") or []
|
||||
for it in items:
|
||||
if not isinstance(it, dict):
|
||||
continue
|
||||
tid = it.get("id") or it.get("slug") or it.get("theme_id")
|
||||
if isinstance(tid, str) and tid not in seen:
|
||||
seen.add(tid)
|
||||
slugs.append(tid)
|
||||
next_offset = data.get("next_offset")
|
||||
if not next_offset or next_offset == offset:
|
||||
break
|
||||
offset = int(next_offset)
|
||||
return sorted(slugs)
|
||||
|
||||
|
||||
def percentile(values: List[float], pct: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
sv = sorted(values)
|
||||
k = (len(sv) - 1) * pct
|
||||
f = int(k)
|
||||
c = min(f + 1, len(sv) - 1)
|
||||
if f == c:
|
||||
return sv[f]
|
||||
d0 = sv[f] * (c - k)
|
||||
d1 = sv[c] * (k - f)
|
||||
return d0 + d1
|
||||
|
||||
|
||||
def run_loop(base_url: str, slugs: List[str], loops: int, limit: int, warm: bool, path_template: str) -> Dict[str, Any]:
|
||||
latencies: List[float] = []
|
||||
per_slug_counts = {s: 0 for s in slugs}
|
||||
t_start = time.time()
|
||||
for i in range(loops):
|
||||
slug = slugs[i % len(slugs)]
|
||||
# path_template may contain {slug} and {limit}
|
||||
try:
|
||||
rel = path_template.format(slug=slug, limit=limit)
|
||||
except Exception:
|
||||
rel = f"/themes/api/theme/{slug}/preview?limit={limit}"
|
||||
if not rel.startswith('/'):
|
||||
rel = '/' + rel
|
||||
url = f"{base_url.rstrip('/')}{rel}"
|
||||
t0 = time.time()
|
||||
try:
|
||||
_fetch_json(url)
|
||||
except Exception as e:
|
||||
print(json.dumps({"event": "perf_benchmark_error", "slug": slug, "error": str(e)})) # noqa: T201
|
||||
continue
|
||||
ms = (time.time() - t0) * 1000.0
|
||||
latencies.append(ms)
|
||||
per_slug_counts[slug] += 1
|
||||
elapsed = time.time() - t_start
|
||||
return {
|
||||
"warm": warm,
|
||||
"loops": loops,
|
||||
"slugs": slugs,
|
||||
"per_slug_requests": per_slug_counts,
|
||||
"elapsed_s": round(elapsed, 3),
|
||||
"p50_ms": round(percentile(latencies, 0.50), 2),
|
||||
"p90_ms": round(percentile(latencies, 0.90), 2),
|
||||
"p95_ms": round(percentile(latencies, 0.95), 2),
|
||||
"avg_ms": round(statistics.mean(latencies), 2) if latencies else 0.0,
|
||||
"count": len(latencies),
|
||||
"_latencies": latencies, # internal (removed in final result unless explicitly retained)
|
||||
}
|
||||
|
||||
|
||||
def _stats_from_latencies(latencies: List[float]) -> Dict[str, Any]:
|
||||
if not latencies:
|
||||
return {"count": 0, "p50_ms": 0.0, "p90_ms": 0.0, "p95_ms": 0.0, "avg_ms": 0.0}
|
||||
return {
|
||||
"count": len(latencies),
|
||||
"p50_ms": round(percentile(latencies, 0.50), 2),
|
||||
"p90_ms": round(percentile(latencies, 0.90), 2),
|
||||
"p95_ms": round(percentile(latencies, 0.95), 2),
|
||||
"avg_ms": round(statistics.mean(latencies), 2),
|
||||
}
|
||||
|
||||
|
||||
def main(argv: List[str]) -> int:
|
||||
ap = argparse.ArgumentParser(description="Theme preview performance benchmark")
|
||||
ap.add_argument("--url", default="http://localhost:8000", help="Base server URL (default: %(default)s)")
|
||||
ap.add_argument("--themes", type=int, default=6, help="Number of theme slugs to exercise (default: %(default)s)")
|
||||
ap.add_argument("--loops", type=int, default=60, help="Total request iterations (default: %(default)s)")
|
||||
ap.add_argument("--limit", type=int, default=12, help="Preview size (default: %(default)s)")
|
||||
ap.add_argument("--path-template", default="/themes/api/theme/{slug}/preview?limit={limit}", help="Format string for preview request path (default: %(default)s)")
|
||||
ap.add_argument("--theme", action="append", dest="explicit_theme", help="Explicit theme slug(s); overrides automatic selection")
|
||||
ap.add_argument("--warm", type=int, default=1, help="Number of warm-up loops (full cycles over selected slugs) (default: %(default)s)")
|
||||
ap.add_argument("--output", type=Path, help="Optional JSON output path (committed under logs/perf)")
|
||||
ap.add_argument("--all", action="store_true", help="Exercise ALL themes (ignores --themes; loops auto-set to passes*total_slugs unless --loops-explicit)")
|
||||
ap.add_argument("--passes", type=int, default=1, help="When using --all, number of passes over the full theme set (default: %(default)s)")
|
||||
# Hidden flag to detect if user explicitly set --loops (argparse has no direct support, so use sentinel technique)
|
||||
# We keep original --loops for backwards compatibility; when --all we recompute unless user passed --loops-explicit
|
||||
ap.add_argument("--loops-explicit", action="store_true", help=argparse.SUPPRESS)
|
||||
ap.add_argument("--extract-warm-baseline", type=Path, help="If multi-pass (--all --passes >1), write a warm-only baseline JSON (final pass stats) to this path")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
try:
|
||||
if args.explicit_theme:
|
||||
slugs = args.explicit_theme
|
||||
elif args.all:
|
||||
slugs = fetch_all_theme_slugs(args.url)
|
||||
else:
|
||||
slugs = select_theme_slugs(args.url, args.themes)
|
||||
except SystemExit as e: # pragma: no cover - dependency on live server
|
||||
print(str(e), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
mode = "all" if args.all else "subset"
|
||||
total_slugs = len(slugs)
|
||||
if args.all and not args.loops_explicit:
|
||||
# Derive loops = passes * total_slugs
|
||||
args.loops = max(1, args.passes) * total_slugs
|
||||
|
||||
print(json.dumps({ # noqa: T201
|
||||
"event": "preview_perf_start",
|
||||
"mode": mode,
|
||||
"total_slugs": total_slugs,
|
||||
"planned_loops": args.loops,
|
||||
"passes": args.passes if args.all else None,
|
||||
}))
|
||||
|
||||
# Execution paths:
|
||||
# 1. Standard subset or single-pass all: warm cycles -> single measured run
|
||||
# 2. Multi-pass all mode (--all --passes >1): iterate passes capturing per-pass stats (no separate warm loops)
|
||||
if args.all and args.passes > 1:
|
||||
pass_results: List[Dict[str, Any]] = []
|
||||
combined_latencies: List[float] = []
|
||||
t0_all = time.time()
|
||||
for p in range(1, args.passes + 1):
|
||||
r = run_loop(args.url, slugs, len(slugs), args.limit, warm=(p == 1), path_template=args.path_template)
|
||||
lat = r.pop("_latencies", [])
|
||||
combined_latencies.extend(lat)
|
||||
pass_result = {
|
||||
"pass": p,
|
||||
"warm": r["warm"],
|
||||
"elapsed_s": r["elapsed_s"],
|
||||
"p50_ms": r["p50_ms"],
|
||||
"p90_ms": r["p90_ms"],
|
||||
"p95_ms": r["p95_ms"],
|
||||
"avg_ms": r["avg_ms"],
|
||||
"count": r["count"],
|
||||
}
|
||||
pass_results.append(pass_result)
|
||||
total_elapsed = round(time.time() - t0_all, 3)
|
||||
aggregate = _stats_from_latencies(combined_latencies)
|
||||
result = {
|
||||
"mode": mode,
|
||||
"total_slugs": total_slugs,
|
||||
"passes": args.passes,
|
||||
"slugs": slugs,
|
||||
"combined": {
|
||||
**aggregate,
|
||||
"elapsed_s": total_elapsed,
|
||||
},
|
||||
"passes_results": pass_results,
|
||||
"cold_pass_p95_ms": pass_results[0]["p95_ms"],
|
||||
"warm_pass_p95_ms": pass_results[-1]["p95_ms"],
|
||||
"cold_pass_p50_ms": pass_results[0]["p50_ms"],
|
||||
"warm_pass_p50_ms": pass_results[-1]["p50_ms"],
|
||||
}
|
||||
print(json.dumps({"event": "preview_perf_result", **result}, indent=2)) # noqa: T201
|
||||
# Optional warm baseline extraction (final pass only; represents warmed steady-state)
|
||||
if args.extract_warm_baseline:
|
||||
try:
|
||||
wb = pass_results[-1]
|
||||
warm_obj = {
|
||||
"event": "preview_perf_warm_baseline",
|
||||
"mode": mode,
|
||||
"total_slugs": total_slugs,
|
||||
"warm_baseline": True,
|
||||
"source_pass": wb["pass"],
|
||||
"p50_ms": wb["p50_ms"],
|
||||
"p90_ms": wb["p90_ms"],
|
||||
"p95_ms": wb["p95_ms"],
|
||||
"avg_ms": wb["avg_ms"],
|
||||
"count": wb["count"],
|
||||
"slugs": slugs,
|
||||
}
|
||||
args.extract_warm_baseline.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.extract_warm_baseline.write_text(json.dumps(warm_obj, indent=2, sort_keys=True), encoding="utf-8")
|
||||
print(json.dumps({ # noqa: T201
|
||||
"event": "preview_perf_warm_baseline_written",
|
||||
"path": str(args.extract_warm_baseline),
|
||||
"p95_ms": wb["p95_ms"],
|
||||
}))
|
||||
except Exception as e: # pragma: no cover
|
||||
print(json.dumps({"event": "preview_perf_warm_baseline_error", "error": str(e)})) # noqa: T201
|
||||
else:
|
||||
# Warm-up loops first (if requested)
|
||||
for w in range(args.warm):
|
||||
run_loop(args.url, slugs, len(slugs), args.limit, warm=True, path_template=args.path_template)
|
||||
result = run_loop(args.url, slugs, args.loops, args.limit, warm=False, path_template=args.path_template)
|
||||
result.pop("_latencies", None)
|
||||
result["slugs"] = slugs
|
||||
result["mode"] = mode
|
||||
result["total_slugs"] = total_slugs
|
||||
if args.all:
|
||||
result["passes"] = args.passes
|
||||
print(json.dumps({"event": "preview_perf_result", **result}, indent=2)) # noqa: T201
|
||||
|
||||
if args.output:
|
||||
try:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Ensure we write the final result object (multi-pass already prepared above)
|
||||
args.output.write_text(json.dumps(result, indent=2, sort_keys=True), encoding="utf-8")
|
||||
except Exception as e: # pragma: no cover
|
||||
print(f"ERROR: failed writing output file: {e}", file=sys.stderr)
|
||||
return 3
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
75
code/scripts/preview_perf_ci_check.py
Normal file
75
code/scripts/preview_perf_ci_check.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""CI helper: run a warm-pass benchmark candidate (single pass over all themes)
|
||||
then compare against the committed warm baseline with threshold enforcement.
|
||||
|
||||
Intended usage (example):
|
||||
python -m code.scripts.preview_perf_ci_check --url http://localhost:8080 \
|
||||
--baseline logs/perf/theme_preview_warm_baseline.json --p95-threshold 5
|
||||
|
||||
Exit codes:
|
||||
0 success (within threshold)
|
||||
2 regression (p95 delta > threshold)
|
||||
3 setup / usage error
|
||||
|
||||
Notes:
|
||||
- Uses --all --passes 1 to create a fresh candidate snapshot that approximates
|
||||
a warmed steady-state (server should have background refresh / typical load).
|
||||
- If you prefer multi-pass then warm-only selection, adjust logic accordingly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def run(cmd: list[str]) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
ap = argparse.ArgumentParser(description="Preview performance CI regression gate")
|
||||
ap.add_argument("--url", default="http://localhost:8080", help="Base URL of running web service")
|
||||
ap.add_argument("--baseline", type=Path, required=True, help="Path to committed warm baseline JSON")
|
||||
ap.add_argument("--p95-threshold", type=float, default=5.0, help="Max allowed p95 regression percent (default: %(default)s)")
|
||||
ap.add_argument("--candidate-output", type=Path, default=Path("logs/perf/theme_preview_ci_candidate.json"), help="Where to write candidate benchmark JSON")
|
||||
ap.add_argument("--multi-pass", action="store_true", help="Run a 2-pass all-themes benchmark and compare warm pass only (optional enhancement)")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if not args.baseline.exists():
|
||||
print(json.dumps({"event":"ci_perf_error","message":"Baseline not found","path":str(args.baseline)}))
|
||||
return 3
|
||||
|
||||
# Run candidate single-pass all-themes benchmark (no extra warm cycles to keep CI fast)
|
||||
# If multi-pass requested, run two passes over all themes so second pass represents warmed steady-state.
|
||||
passes = "2" if args.multi_pass else "1"
|
||||
bench_cmd = [sys.executable, "-m", "code.scripts.preview_perf_benchmark", "--url", args.url, "--all", "--passes", passes, "--output", str(args.candidate_output)]
|
||||
bench_proc = run(bench_cmd)
|
||||
if bench_proc.returncode != 0:
|
||||
print(json.dumps({"event":"ci_perf_error","stage":"benchmark","code":bench_proc.returncode,"stderr":bench_proc.stderr}))
|
||||
return 3
|
||||
print(bench_proc.stdout)
|
||||
|
||||
if not args.candidate_output.exists():
|
||||
print(json.dumps({"event":"ci_perf_error","message":"Candidate output missing"}))
|
||||
return 3
|
||||
|
||||
compare_cmd = [
|
||||
sys.executable,
|
||||
"-m","code.scripts.preview_perf_compare",
|
||||
"--baseline", str(args.baseline),
|
||||
"--candidate", str(args.candidate_output),
|
||||
"--warm-only",
|
||||
"--p95-threshold", str(args.p95_threshold),
|
||||
]
|
||||
cmp_proc = run(compare_cmd)
|
||||
print(cmp_proc.stdout)
|
||||
if cmp_proc.returncode == 2:
|
||||
# Already printed JSON with failure status
|
||||
return 2
|
||||
if cmp_proc.returncode != 0:
|
||||
print(json.dumps({"event":"ci_perf_error","stage":"compare","code":cmp_proc.returncode,"stderr":cmp_proc.stderr}))
|
||||
return 3
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
115
code/scripts/preview_perf_compare.py
Normal file
115
code/scripts/preview_perf_compare.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Compare two preview benchmark JSON result files and emit delta stats.
|
||||
|
||||
Usage:
|
||||
python -m code.scripts.preview_perf_compare --baseline logs/perf/theme_preview_baseline_all_pass1_20250923.json --candidate logs/perf/new_run.json
|
||||
|
||||
Outputs JSON with percentage deltas for p50/p90/p95/avg (positive = regression/slower).
|
||||
If multi-pass structures are present (combined & passes_results) those are included.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def load(path: Path) -> Dict[str, Any]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
# Multi-pass result may store stats under combined
|
||||
if "combined" in data:
|
||||
core = data["combined"].copy()
|
||||
# Inject representative fields for uniform comparison
|
||||
core["p50_ms"] = core.get("p50_ms") or data.get("p50_ms")
|
||||
core["p90_ms"] = core.get("p90_ms") or data.get("p90_ms")
|
||||
core["p95_ms"] = core.get("p95_ms") or data.get("p95_ms")
|
||||
core["avg_ms"] = core.get("avg_ms") or data.get("avg_ms")
|
||||
data["_core_stats"] = core
|
||||
else:
|
||||
data["_core_stats"] = {
|
||||
k: data.get(k) for k in ("p50_ms", "p90_ms", "p95_ms", "avg_ms", "count")
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
def pct_delta(new: float, old: float) -> float:
|
||||
if old == 0:
|
||||
return 0.0
|
||||
return round(((new - old) / old) * 100.0, 2)
|
||||
|
||||
|
||||
def compare(baseline: Dict[str, Any], candidate: Dict[str, Any]) -> Dict[str, Any]:
|
||||
b = baseline["_core_stats"]
|
||||
c = candidate["_core_stats"]
|
||||
result = {"baseline_count": b.get("count"), "candidate_count": c.get("count")}
|
||||
for k in ("p50_ms", "p90_ms", "p95_ms", "avg_ms"):
|
||||
if b.get(k) is not None and c.get(k) is not None:
|
||||
result[k] = {
|
||||
"baseline": b[k],
|
||||
"candidate": c[k],
|
||||
"delta_pct": pct_delta(c[k], b[k]),
|
||||
}
|
||||
# If both have per-pass details include first and last pass p95/p50
|
||||
if "passes_results" in baseline and "passes_results" in candidate:
|
||||
result["passes"] = {
|
||||
"baseline": {
|
||||
"cold_p95": baseline.get("cold_pass_p95_ms"),
|
||||
"warm_p95": baseline.get("warm_pass_p95_ms"),
|
||||
"cold_p50": baseline.get("cold_pass_p50_ms"),
|
||||
"warm_p50": baseline.get("warm_pass_p50_ms"),
|
||||
},
|
||||
"candidate": {
|
||||
"cold_p95": candidate.get("cold_pass_p95_ms"),
|
||||
"warm_p95": candidate.get("warm_pass_p95_ms"),
|
||||
"cold_p50": candidate.get("cold_pass_p50_ms"),
|
||||
"warm_p50": candidate.get("warm_pass_p50_ms"),
|
||||
},
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
ap = argparse.ArgumentParser(description="Compare two preview benchmark JSON result files")
|
||||
ap.add_argument("--baseline", required=True, type=Path, help="Baseline JSON path")
|
||||
ap.add_argument("--candidate", required=True, type=Path, help="Candidate JSON path")
|
||||
ap.add_argument("--p95-threshold", type=float, default=None, help="Fail (exit 2) if p95 regression exceeds this percent (positive delta)")
|
||||
ap.add_argument("--warm-only", action="store_true", help="When both results have passes, compare warm pass p95/p50 instead of combined/core")
|
||||
args = ap.parse_args(argv)
|
||||
if not args.baseline.exists():
|
||||
raise SystemExit(f"Baseline not found: {args.baseline}")
|
||||
if not args.candidate.exists():
|
||||
raise SystemExit(f"Candidate not found: {args.candidate}")
|
||||
baseline = load(args.baseline)
|
||||
candidate = load(args.candidate)
|
||||
# If warm-only requested and both have warm pass stats, override _core_stats before compare
|
||||
if args.warm_only and "warm_pass_p95_ms" in baseline and "warm_pass_p95_ms" in candidate:
|
||||
baseline["_core_stats"] = {
|
||||
"p50_ms": baseline.get("warm_pass_p50_ms"),
|
||||
"p90_ms": baseline.get("_core_stats", {}).get("p90_ms"), # p90 not tracked per-pass; retain combined
|
||||
"p95_ms": baseline.get("warm_pass_p95_ms"),
|
||||
"avg_ms": baseline.get("_core_stats", {}).get("avg_ms"),
|
||||
"count": baseline.get("_core_stats", {}).get("count"),
|
||||
}
|
||||
candidate["_core_stats"] = {
|
||||
"p50_ms": candidate.get("warm_pass_p50_ms"),
|
||||
"p90_ms": candidate.get("_core_stats", {}).get("p90_ms"),
|
||||
"p95_ms": candidate.get("warm_pass_p95_ms"),
|
||||
"avg_ms": candidate.get("_core_stats", {}).get("avg_ms"),
|
||||
"count": candidate.get("_core_stats", {}).get("count"),
|
||||
}
|
||||
cmp = compare(baseline, candidate)
|
||||
payload = {"event": "preview_perf_compare", **cmp}
|
||||
if args.p95_threshold is not None and "p95_ms" in cmp:
|
||||
delta = cmp["p95_ms"]["delta_pct"]
|
||||
payload["threshold"] = {"p95_threshold": args.p95_threshold, "p95_delta_pct": delta}
|
||||
if delta is not None and delta > args.p95_threshold:
|
||||
payload["result"] = "fail"
|
||||
print(json.dumps(payload, indent=2)) # noqa: T201
|
||||
return 2
|
||||
payload["result"] = "pass"
|
||||
print(json.dumps(payload, indent=2)) # noqa: T201
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main(__import__('sys').argv[1:]))
|
||||
94
code/scripts/snapshot_taxonomy.py
Normal file
94
code/scripts/snapshot_taxonomy.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Snapshot the current power bracket taxonomy to a dated JSON artifact.
|
||||
|
||||
Outputs a JSON file under logs/taxonomy_snapshots/ named
|
||||
taxonomy_<YYYYMMDD>_<HHMMSS>.json
|
||||
containing:
|
||||
{
|
||||
"generated_at": ISO8601,
|
||||
"hash": sha256 hex of canonical payload (excluding this top-level wrapper),
|
||||
"brackets": [ {level,name,short_desc,long_desc,limits} ... ]
|
||||
}
|
||||
|
||||
If a snapshot with identical hash already exists today, creation is skipped
|
||||
unless --force provided.
|
||||
|
||||
Usage (from repo root):
|
||||
python -m code.scripts.snapshot_taxonomy
|
||||
python -m code.scripts.snapshot_taxonomy --force
|
||||
|
||||
Intended to provide an auditable evolution trail for taxonomy adjustments
|
||||
before we implement taxonomy-aware sampling changes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from code.deck_builder.phases.phase0_core import BRACKET_DEFINITIONS
|
||||
|
||||
SNAP_DIR = Path("logs/taxonomy_snapshots")
|
||||
SNAP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _canonical_brackets():
|
||||
return [
|
||||
{
|
||||
"level": b.level,
|
||||
"name": b.name,
|
||||
"short_desc": b.short_desc,
|
||||
"long_desc": b.long_desc,
|
||||
"limits": b.limits,
|
||||
}
|
||||
for b in sorted(BRACKET_DEFINITIONS, key=lambda x: x.level)
|
||||
]
|
||||
|
||||
|
||||
def compute_hash(brackets) -> str:
|
||||
# Canonical JSON with sorted keys for repeatable hash
|
||||
payload = json.dumps(brackets, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def find_existing_hashes() -> Dict[str, Path]:
|
||||
existing = {}
|
||||
for p in SNAP_DIR.glob("taxonomy_*.json"):
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
h = data.get("hash")
|
||||
if h:
|
||||
existing[h] = p
|
||||
except Exception:
|
||||
continue
|
||||
return existing
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--force", action="store_true", help="Write new snapshot even if identical hash exists today")
|
||||
args = ap.parse_args()
|
||||
|
||||
brackets = _canonical_brackets()
|
||||
h = compute_hash(brackets)
|
||||
existing = find_existing_hashes()
|
||||
if h in existing and not args.force:
|
||||
print(f"Snapshot identical (hash={h[:12]}...) exists: {existing[h].name}; skipping.")
|
||||
return 0
|
||||
|
||||
ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
out = SNAP_DIR / f"taxonomy_{ts}.json"
|
||||
wrapper: Dict[str, Any] = {
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
"hash": h,
|
||||
"brackets": brackets,
|
||||
}
|
||||
out.write_text(json.dumps(wrapper, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(f"Wrote taxonomy snapshot {out} (hash={h[:12]}...)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
44
code/tests/test_card_index_color_identity_edge_cases.py
Normal file
44
code/tests/test_card_index_color_identity_edge_cases.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from code.web.services import card_index
|
||||
|
||||
CSV_CONTENT = """name,themeTags,colorIdentity,manaCost,rarity
|
||||
Hybrid Test,"Blink",WG,{W/G}{W/G},uncommon
|
||||
Devoid Test,"Blink",C,3U,uncommon
|
||||
MDFC Front,"Blink",R,1R,rare
|
||||
Adventure Card,"Blink",G,2G,common
|
||||
Color Indicator,"Blink",U,2U,uncommon
|
||||
"""
|
||||
|
||||
# Note: The simplified edge cases focus on color_identity_list extraction logic.
|
||||
|
||||
def write_csv(tmp_path: Path):
|
||||
p = tmp_path / "synthetic_edge_cases.csv"
|
||||
p.write_text(CSV_CONTENT, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def test_card_index_color_identity_list_handles_edge_cases(tmp_path, monkeypatch):
|
||||
csv_path = write_csv(tmp_path)
|
||||
monkeypatch.setenv("CARD_INDEX_EXTRA_CSV", str(csv_path))
|
||||
# Force rebuild
|
||||
card_index._CARD_INDEX.clear() # type: ignore
|
||||
card_index._CARD_INDEX_MTIME = None # type: ignore
|
||||
card_index.maybe_build_index()
|
||||
|
||||
pool = card_index.get_tag_pool("Blink")
|
||||
names = {c["name"]: c for c in pool}
|
||||
assert {"Hybrid Test", "Devoid Test", "MDFC Front", "Adventure Card", "Color Indicator"}.issubset(names.keys())
|
||||
|
||||
# Hybrid Test: colorIdentity WG -> list should be ["W", "G"]
|
||||
assert names["Hybrid Test"]["color_identity_list"] == ["W", "G"]
|
||||
# Devoid Test: colorless identity C -> list empty (colorless)
|
||||
assert names["Devoid Test"]["color_identity_list"] == [] or names["Devoid Test"]["color_identity"] in ("", "C")
|
||||
# MDFC Front: single color R
|
||||
assert names["MDFC Front"]["color_identity_list"] == ["R"]
|
||||
# Adventure Card: single color G
|
||||
assert names["Adventure Card"]["color_identity_list"] == ["G"]
|
||||
# Color Indicator: single color U
|
||||
assert names["Color Indicator"]["color_identity_list"] == ["U"]
|
||||
30
code/tests/test_card_index_rarity_normalization.py
Normal file
30
code/tests/test_card_index_rarity_normalization.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import csv
|
||||
from code.web.services import card_index
|
||||
|
||||
def test_rarity_normalization_and_duplicate_handling(tmp_path, monkeypatch):
|
||||
# Create a temporary CSV simulating duplicate rarities and variant casing
|
||||
csv_path = tmp_path / "cards.csv"
|
||||
rows = [
|
||||
{"name": "Alpha Beast", "themeTags": "testtheme", "colorIdentity": "G", "manaCost": "3G", "rarity": "MyThic"},
|
||||
{"name": "Alpha Beast", "themeTags": "othertheme", "colorIdentity": "G", "manaCost": "3G", "rarity": "MYTHIC RARE"},
|
||||
{"name": "Helper Sprite", "themeTags": "testtheme", "colorIdentity": "U", "manaCost": "1U", "rarity": "u"},
|
||||
{"name": "Common Grunt", "themeTags": "testtheme", "colorIdentity": "R", "manaCost": "1R", "rarity": "COMMON"},
|
||||
]
|
||||
with csv_path.open("w", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.DictWriter(fh, fieldnames=["name","themeTags","colorIdentity","manaCost","rarity"])
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
# Monkeypatch CARD_FILES_GLOB to only use our temp file
|
||||
monkeypatch.setattr(card_index, "CARD_FILES_GLOB", [csv_path])
|
||||
|
||||
card_index.maybe_build_index()
|
||||
pool = card_index.get_tag_pool("testtheme")
|
||||
# Expect three entries for testtheme (Alpha Beast (first occurrence), Helper Sprite, Common Grunt)
|
||||
names = sorted(c["name"] for c in pool)
|
||||
assert names == ["Alpha Beast", "Common Grunt", "Helper Sprite"]
|
||||
# Assert rarity normalization collapsed variants
|
||||
rarities = {c["name"]: c["rarity"] for c in pool}
|
||||
assert rarities["Alpha Beast"] == "mythic"
|
||||
assert rarities["Helper Sprite"] == "uncommon"
|
||||
assert rarities["Common Grunt"] == "common"
|
||||
23
code/tests/test_preview_bg_refresh_thread.py
Normal file
23
code/tests/test_preview_bg_refresh_thread.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import time
|
||||
from importlib import reload
|
||||
|
||||
from code.web.services import preview_cache as pc
|
||||
from code.web.services import theme_preview as tp
|
||||
|
||||
|
||||
def test_background_refresh_thread_flag(monkeypatch):
|
||||
# Enable background refresh via env
|
||||
monkeypatch.setenv("THEME_PREVIEW_BG_REFRESH", "1")
|
||||
# Reload preview_cache to re-evaluate env flags
|
||||
reload(pc)
|
||||
# Simulate a couple of builds to trigger ensure_bg_thread
|
||||
# Use a real theme id by invoking preview on first catalog slug
|
||||
from code.web.services.theme_catalog_loader import load_index
|
||||
idx = load_index()
|
||||
slug = sorted(idx.slug_to_entry.keys())[0]
|
||||
for _ in range(2):
|
||||
tp.get_theme_preview(slug, limit=4)
|
||||
time.sleep(0.01)
|
||||
# Background thread flag should be set if enabled
|
||||
assert getattr(pc, "_BG_REFRESH_ENABLED", False) is True
|
||||
assert getattr(pc, "_BG_REFRESH_THREAD_STARTED", False) is True, "background refresh thread did not start"
|
||||
36
code/tests/test_preview_cache_redis_poc.py
Normal file
36
code/tests/test_preview_cache_redis_poc.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import os
|
||||
import importlib
|
||||
import types
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
fastapi = pytest.importorskip("fastapi")
|
||||
|
||||
|
||||
def load_app_with_env(**env: str) -> types.ModuleType:
|
||||
for k,v in env.items():
|
||||
os.environ[k] = v
|
||||
import code.web.app as app_module # type: ignore
|
||||
importlib.reload(app_module)
|
||||
return app_module
|
||||
|
||||
|
||||
def test_redis_poc_graceful_fallback_no_library():
|
||||
# Provide fake redis URL but do NOT install redis lib; should not raise and metrics should include redis_get_attempts field (0 ok)
|
||||
app_module = load_app_with_env(THEME_PREVIEW_REDIS_URL="redis://localhost:6379/0")
|
||||
client = TestClient(app_module.app)
|
||||
# Hit a preview endpoint to generate metrics baseline (choose a theme slug present in catalog list page)
|
||||
# Use themes list to discover one quickly
|
||||
r = client.get('/themes/')
|
||||
assert r.status_code == 200
|
||||
# Invoke metrics endpoint (assuming existing route /themes/metrics or similar). If absent, skip.
|
||||
# We do not know exact path; fallback: ensure service still runs.
|
||||
# Try known metrics accessor used in other tests: preview metrics exposed via service function? We'll attempt /themes/metrics.
|
||||
m = client.get('/themes/metrics')
|
||||
if m.status_code == 200:
|
||||
data = m.json()
|
||||
# Assert redis metric keys present
|
||||
assert 'redis_get_attempts' in data
|
||||
assert 'redis_get_hits' in data
|
||||
else:
|
||||
pytest.skip('metrics endpoint not present; redis poc fallback still validated by absence of errors')
|
||||
105
code/tests/test_preview_eviction_advanced.py
Normal file
105
code/tests/test_preview_eviction_advanced.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import os
|
||||
|
||||
from code.web.services.theme_preview import get_theme_preview, bust_preview_cache # type: ignore
|
||||
from code.web.services import preview_cache as pc # type: ignore
|
||||
from code.web.services.preview_metrics import preview_metrics # type: ignore
|
||||
|
||||
|
||||
def _prime(slug: str, limit: int = 12, hits: int = 0, *, colors=None):
|
||||
get_theme_preview(slug, limit=limit, colors=colors)
|
||||
for _ in range(hits):
|
||||
get_theme_preview(slug, limit=limit, colors=colors) # cache hits
|
||||
|
||||
|
||||
def test_cost_bias_protection(monkeypatch):
|
||||
"""Higher build_cost_ms entries should survive versus cheap low-hit entries.
|
||||
|
||||
We simulate by manually injecting varied build_cost_ms then forcing eviction.
|
||||
"""
|
||||
os.environ['THEME_PREVIEW_CACHE_MAX'] = '6'
|
||||
bust_preview_cache()
|
||||
# Build 6 entries
|
||||
base_key_parts = []
|
||||
color_cycle = [None, 'W', 'U', 'B', 'R', 'G']
|
||||
for i in range(6):
|
||||
payload = get_theme_preview('Blink', limit=6, colors=color_cycle[i % len(color_cycle)])
|
||||
base_key_parts.append(payload['theme_id'])
|
||||
# Manually adjust build_cost_ms to create one very expensive entry and some cheap ones.
|
||||
# Choose first key deterministically.
|
||||
expensive_key = next(iter(pc.PREVIEW_CACHE.keys()))
|
||||
pc.PREVIEW_CACHE[expensive_key]['build_cost_ms'] = 120.0 # place in highest bucket
|
||||
# Mark others as very cheap
|
||||
for k, v in pc.PREVIEW_CACHE.items():
|
||||
if k != expensive_key:
|
||||
v['build_cost_ms'] = 1.0
|
||||
# Force new insertion to trigger eviction
|
||||
get_theme_preview('Blink', limit=6, colors='X')
|
||||
# Expensive key should still be present
|
||||
assert expensive_key in pc.PREVIEW_CACHE
|
||||
m = preview_metrics()
|
||||
assert m['preview_cache_evictions'] >= 1
|
||||
assert m['preview_cache_evictions_by_reason'].get('low_score', 0) >= 1
|
||||
|
||||
|
||||
def test_hot_entry_retention(monkeypatch):
|
||||
"""Entry with many hits should outlive cold entries when eviction occurs."""
|
||||
os.environ['THEME_PREVIEW_CACHE_MAX'] = '5'
|
||||
bust_preview_cache()
|
||||
# Prime one hot entry with multiple hits
|
||||
_prime('Blink', limit=6, hits=5, colors=None)
|
||||
hot_key = next(iter(pc.PREVIEW_CACHE.keys()))
|
||||
# Add additional distinct entries to exceed max
|
||||
for c in ['W','U','B','R','G','X']:
|
||||
get_theme_preview('Blink', limit=6, colors=c)
|
||||
# Ensure cache size within limit & hot entry retained
|
||||
assert len(pc.PREVIEW_CACHE) <= 5
|
||||
assert hot_key in pc.PREVIEW_CACHE, 'Hot entry was evicted unexpectedly'
|
||||
|
||||
|
||||
def test_emergency_overflow_path(monkeypatch):
|
||||
"""If cache grows beyond 2*limit, emergency_overflow evictions should record that reason."""
|
||||
os.environ['THEME_PREVIEW_CACHE_MAX'] = '4'
|
||||
bust_preview_cache()
|
||||
# Temporarily monkeypatch _cache_max to simulate sudden lower limit AFTER many insertions
|
||||
# Insert > 8 entries first (using varying limits to vary key tuples)
|
||||
for i, c in enumerate(['W','U','B','R','G','X','C','M','N']):
|
||||
get_theme_preview('Blink', limit=6, colors=c)
|
||||
# Confirm we exceeded 2*limit (cache_max returns at least 50 internally so override via env not enough)
|
||||
# We patch pc._cache_max directly to enforce small limit for test.
|
||||
monkeypatch.setattr(pc, '_cache_max', lambda: 4)
|
||||
# Now call eviction directly
|
||||
pc.evict_if_needed()
|
||||
m = preview_metrics()
|
||||
# Either emergency_overflow or multiple low_score evictions until limit; ensure size reduced.
|
||||
assert len(pc.PREVIEW_CACHE) <= 50 # guard (internal min), but we expect <= original internal min
|
||||
# Look for emergency_overflow reason occurrence (best effort; may not trigger if size not > 2*limit after min bound)
|
||||
# We allow pass if at least one eviction occurred.
|
||||
assert m['preview_cache_evictions'] >= 1
|
||||
|
||||
|
||||
def test_env_weight_override(monkeypatch):
|
||||
"""Changing weight env vars should alter protection score ordering.
|
||||
|
||||
We set W_HITS very low and W_AGE high so older entry with many hits can be evicted.
|
||||
"""
|
||||
os.environ['THEME_PREVIEW_CACHE_MAX'] = '5'
|
||||
os.environ['THEME_PREVIEW_EVICT_W_HITS'] = '0.1'
|
||||
os.environ['THEME_PREVIEW_EVICT_W_AGE'] = '5.0'
|
||||
# Bust and clear cached weight memoization
|
||||
bust_preview_cache()
|
||||
# Clear module-level caches for weights
|
||||
if hasattr(pc, '_EVICT_WEIGHTS_CACHE'):
|
||||
pc._EVICT_WEIGHTS_CACHE = None # type: ignore
|
||||
# Create two entries: one older with many hits, one fresh with none.
|
||||
_prime('Blink', limit=6, hits=6, colors=None) # older hot entry
|
||||
old_key = next(iter(pc.PREVIEW_CACHE.keys()))
|
||||
# Age the first entry slightly
|
||||
pc.PREVIEW_CACHE[old_key]['inserted_at'] -= 120 # 2 minutes ago
|
||||
# Add fresh entries to trigger eviction
|
||||
for c in ['W','U','B','R','G','X']:
|
||||
get_theme_preview('Blink', limit=6, colors=c)
|
||||
# With age weight high and hits weight low, old hot entry can be evicted
|
||||
# Not guaranteed deterministically; assert only that at least one eviction happened and metrics show low_score.
|
||||
m = preview_metrics()
|
||||
assert m['preview_cache_evictions'] >= 1
|
||||
assert 'low_score' in m['preview_cache_evictions_by_reason']
|
||||
23
code/tests/test_preview_eviction_basic.py
Normal file
23
code/tests/test_preview_eviction_basic.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import os
|
||||
from code.web.services.theme_preview import get_theme_preview, bust_preview_cache # type: ignore
|
||||
from code.web.services import preview_cache as pc # type: ignore
|
||||
|
||||
|
||||
def test_basic_low_score_eviction(monkeypatch):
|
||||
"""Populate cache past limit using distinct color filters to force eviction."""
|
||||
os.environ['THEME_PREVIEW_CACHE_MAX'] = '5'
|
||||
bust_preview_cache()
|
||||
colors_seq = [None, 'W', 'U', 'B', 'R', 'G'] # 6 unique keys (slug, limit fixed, colors vary)
|
||||
# Prime first key with an extra hit to increase protection
|
||||
first_color = colors_seq[0]
|
||||
get_theme_preview('Blink', limit=6, colors=first_color)
|
||||
get_theme_preview('Blink', limit=6, colors=first_color) # hit
|
||||
# Insert remaining distinct keys
|
||||
for c in colors_seq[1:]:
|
||||
get_theme_preview('Blink', limit=6, colors=c)
|
||||
# Cache limit 5, inserted 6 distinct -> eviction should have occurred
|
||||
assert len(pc.PREVIEW_CACHE) <= 5
|
||||
from code.web.services.preview_metrics import preview_metrics # type: ignore
|
||||
m = preview_metrics()
|
||||
assert m['preview_cache_evictions'] >= 1, 'Expected at least one eviction'
|
||||
assert m['preview_cache_evictions_by_reason'].get('low_score', 0) >= 1
|
||||
58
code/tests/test_preview_export_endpoints.py
Normal file
58
code/tests/test_preview_export_endpoints.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from typing import Set
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from code.web.app import app # FastAPI instance
|
||||
from code.web.services.theme_catalog_loader import load_index
|
||||
|
||||
|
||||
def _first_theme_slug() -> str:
|
||||
idx = load_index()
|
||||
# Deterministic ordering for test stability
|
||||
return sorted(idx.slug_to_entry.keys())[0]
|
||||
|
||||
|
||||
def test_preview_export_json_and_csv_curated_only_round_trip():
|
||||
slug = _first_theme_slug()
|
||||
client = TestClient(app)
|
||||
|
||||
# JSON full sample
|
||||
r = client.get(f"/themes/preview/{slug}/export.json", params={"curated_only": 0, "limit": 12})
|
||||
assert r.status_code == 200, r.text
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
assert data["theme_id"] == slug
|
||||
assert data["count"] == len(data["items"]) <= 12 # noqa: SIM300
|
||||
required_keys_sampled = {"name", "roles", "score", "rarity", "mana_cost", "color_identity_list", "pip_colors"}
|
||||
sampled_role_set = {"payoff", "enabler", "support", "wildcard"}
|
||||
assert data["items"], "expected non-empty preview sample"
|
||||
for item in data["items"]:
|
||||
roles = set(item.get("roles") or [])
|
||||
# Curated examples & synthetic placeholders don't currently carry full card DB fields
|
||||
if roles.intersection(sampled_role_set):
|
||||
assert required_keys_sampled.issubset(item.keys()), f"sampled card missing expected fields: {item}"
|
||||
else:
|
||||
assert {"name", "roles", "score"}.issubset(item.keys())
|
||||
|
||||
# JSON curated_only variant: ensure only curated/synthetic roles remain
|
||||
r2 = client.get(f"/themes/preview/{slug}/export.json", params={"curated_only": 1, "limit": 12})
|
||||
assert r2.status_code == 200, r2.text
|
||||
curated = r2.json()
|
||||
curated_roles_allowed: Set[str] = {"example", "curated_synergy", "synthetic"}
|
||||
for item in curated["items"]:
|
||||
roles = set(item.get("roles") or [])
|
||||
assert roles, "item missing roles"
|
||||
assert roles.issubset(curated_roles_allowed), f"unexpected sampled role present: {roles}"
|
||||
|
||||
# CSV export header stability + curated_only path
|
||||
r3 = client.get(f"/themes/preview/{slug}/export.csv", params={"curated_only": 1, "limit": 12})
|
||||
assert r3.status_code == 200, r3.text
|
||||
text = r3.text.splitlines()
|
||||
assert text, "empty CSV response"
|
||||
header = text[0].strip()
|
||||
assert header == "name,roles,score,rarity,mana_cost,color_identity_list,pip_colors,reasons,tags"
|
||||
# Basic sanity: curated_only CSV should not contain a sampled role token
|
||||
sampled_role_tokens = {"payoff", "enabler", "support", "wildcard"}
|
||||
body = "\n".join(text[1:])
|
||||
for tok in sampled_role_tokens:
|
||||
assert f";{tok}" not in body, f"sampled role {tok} leaked into curated_only CSV"
|
||||
51
code/tests/test_preview_ttl_adaptive.py
Normal file
51
code/tests/test_preview_ttl_adaptive.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from code.web.services import preview_cache as pc
|
||||
|
||||
|
||||
def _force_interval_elapsed():
|
||||
# Ensure adaptation interval guard passes
|
||||
if pc._LAST_ADAPT_AT is not None: # type: ignore[attr-defined]
|
||||
pc._LAST_ADAPT_AT -= (pc._ADAPT_INTERVAL_S + 1) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_ttl_adapts_down_and_up(capsys):
|
||||
# Enable adaptation regardless of env
|
||||
pc._ADAPTATION_ENABLED = True # type: ignore[attr-defined]
|
||||
pc.TTL_SECONDS = pc._TTL_BASE # type: ignore[attr-defined]
|
||||
pc._RECENT_HITS.clear() # type: ignore[attr-defined]
|
||||
pc._LAST_ADAPT_AT = None # type: ignore[attr-defined]
|
||||
|
||||
# Low hit ratio pattern (~0.1)
|
||||
for _ in range(72):
|
||||
pc.record_request_hit(False)
|
||||
for _ in range(8):
|
||||
pc.record_request_hit(True)
|
||||
pc.maybe_adapt_ttl()
|
||||
out1 = capsys.readouterr().out
|
||||
assert "theme_preview_ttl_adapt" in out1, "expected adaptation log for low hit ratio"
|
||||
ttl_after_down = pc.TTL_SECONDS
|
||||
assert ttl_after_down <= pc._TTL_BASE # type: ignore[attr-defined]
|
||||
|
||||
# Force interval elapsed & high hit ratio pattern (~0.9)
|
||||
_force_interval_elapsed()
|
||||
pc._RECENT_HITS.clear() # type: ignore[attr-defined]
|
||||
for _ in range(72):
|
||||
pc.record_request_hit(True)
|
||||
for _ in range(8):
|
||||
pc.record_request_hit(False)
|
||||
pc.maybe_adapt_ttl()
|
||||
out2 = capsys.readouterr().out
|
||||
assert "theme_preview_ttl_adapt" in out2, "expected adaptation log for high hit ratio"
|
||||
ttl_after_up = pc.TTL_SECONDS
|
||||
assert ttl_after_up >= ttl_after_down
|
||||
# Extract hit_ratio fields to assert directionality if logs present
|
||||
ratios = []
|
||||
for line in (out1 + out2).splitlines():
|
||||
if 'theme_preview_ttl_adapt' in line:
|
||||
import json
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
ratios.append(obj.get('hit_ratio'))
|
||||
except Exception:
|
||||
pass
|
||||
if len(ratios) >= 2:
|
||||
assert ratios[0] < ratios[-1], "expected second adaptation to have higher hit_ratio"
|
||||
41
code/tests/test_sampling_role_saturation.py
Normal file
41
code/tests/test_sampling_role_saturation.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from code.web.services import sampling
|
||||
|
||||
|
||||
def test_role_saturation_penalty_applies(monkeypatch):
|
||||
# Construct a minimal fake pool via monkeypatching card_index.get_tag_pool
|
||||
# We'll generate many payoff-tagged cards to trigger saturation.
|
||||
cards = []
|
||||
for i in range(30):
|
||||
cards.append({
|
||||
"name": f"Payoff{i}",
|
||||
"color_identity": "G",
|
||||
"tags": ["testtheme"], # ensures payoff
|
||||
"mana_cost": "1G",
|
||||
"rarity": "common",
|
||||
"color_identity_list": ["G"],
|
||||
"pip_colors": ["G"],
|
||||
})
|
||||
|
||||
def fake_pool(tag: str):
|
||||
assert tag == "testtheme"
|
||||
return cards
|
||||
|
||||
# Patch symbols where they are used (imported into sampling module)
|
||||
monkeypatch.setattr("code.web.services.sampling.get_tag_pool", lambda tag: fake_pool(tag))
|
||||
monkeypatch.setattr("code.web.services.sampling.maybe_build_index", lambda: None)
|
||||
monkeypatch.setattr("code.web.services.sampling.lookup_commander", lambda name: None)
|
||||
|
||||
chosen = sampling.sample_real_cards_for_theme(
|
||||
theme="testtheme",
|
||||
limit=12,
|
||||
colors_filter=None,
|
||||
synergies=["testtheme"],
|
||||
commander=None,
|
||||
)
|
||||
# Ensure we have more than half flagged as payoff in initial classification
|
||||
payoff_scores = [c["score"] for c in chosen if c["roles"][0] == "payoff"]
|
||||
assert payoff_scores, "Expected payoff cards present"
|
||||
# Saturation penalty should have been applied to at least one (score reduced by 0.4 increments) once cap exceeded.
|
||||
# We detect presence by existence of reason substring.
|
||||
penalized = [c for c in chosen if any(r.startswith("role_saturation_penalty") for r in c.get("reasons", []))]
|
||||
assert penalized, "Expected at least one card to receive role_saturation_penalty"
|
||||
67
code/tests/test_sampling_splash_adaptive.py
Normal file
67
code/tests/test_sampling_splash_adaptive.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from code.web.services.sampling import sample_real_cards_for_theme
|
||||
|
||||
# We'll construct a minimal in-memory index by monkeypatching card_index structures directly
|
||||
# to avoid needing real CSV files. This keeps the test fast & deterministic.
|
||||
|
||||
|
||||
def test_adaptive_splash_penalty_scaling(monkeypatch):
|
||||
# Prepare index
|
||||
theme = "__AdaptiveSplashTest__"
|
||||
# Commander (4-color) enabling splash path
|
||||
commander_name = "Test Commander"
|
||||
commander_tags = [theme, "Value", "ETB"]
|
||||
commander_entry = {
|
||||
"name": commander_name,
|
||||
"color_identity": "WUBR", # 4 colors
|
||||
"tags": commander_tags,
|
||||
"mana_cost": "WUBR",
|
||||
"rarity": "mythic",
|
||||
"color_identity_list": list("WUBR"),
|
||||
"pip_colors": list("WUBR"),
|
||||
}
|
||||
pool = [commander_entry]
|
||||
def add_card(name: str, color_identity: str, tags: list[str]):
|
||||
pool.append({
|
||||
"name": name,
|
||||
"color_identity": color_identity,
|
||||
"tags": tags,
|
||||
"mana_cost": "1G",
|
||||
"rarity": "uncommon",
|
||||
"color_identity_list": list(color_identity),
|
||||
"pip_colors": [c for c in "1G" if c in {"W","U","B","R","G"}],
|
||||
})
|
||||
# On-color payoff (no splash penalty)
|
||||
add_card("On Color Card", "WUB", [theme, "ETB"])
|
||||
# Off-color splash (adds G)
|
||||
add_card("Splash Card", "WUBG", [theme, "ETB", "Synergy"])
|
||||
|
||||
# Monkeypatch lookup_commander to return our commander
|
||||
from code.web.services import card_index as ci
|
||||
# Patch underlying card_index (for direct calls elsewhere)
|
||||
monkeypatch.setattr(ci, "lookup_commander", lambda name: commander_entry if name == commander_name else None)
|
||||
monkeypatch.setattr(ci, "maybe_build_index", lambda: None)
|
||||
monkeypatch.setattr(ci, "get_tag_pool", lambda tag: pool if tag == theme else [])
|
||||
# Also patch symbols imported into sampling at import time
|
||||
import code.web.services.sampling as sampling_mod
|
||||
monkeypatch.setattr(sampling_mod, "maybe_build_index", lambda: None)
|
||||
monkeypatch.setattr(sampling_mod, "get_tag_pool", lambda tag: pool if tag == theme else [])
|
||||
monkeypatch.setattr(sampling_mod, "lookup_commander", lambda name: commander_entry if name == commander_name else None)
|
||||
monkeypatch.setattr(sampling_mod, "SPLASH_ADAPTIVE_ENABLED", True)
|
||||
monkeypatch.setenv("SPLASH_ADAPTIVE", "1")
|
||||
monkeypatch.setenv("SPLASH_ADAPTIVE_SCALE", "1:1.0,2:1.0,3:1.0,4:0.5,5:0.25")
|
||||
|
||||
# Invoke sampler (limit large enough to include both cards)
|
||||
cards = sample_real_cards_for_theme(theme, 10, None, synergies=[theme, "ETB", "Synergy"], commander=commander_name)
|
||||
by_name = {c["name"]: c for c in cards}
|
||||
assert "Splash Card" in by_name, cards
|
||||
splash_reasons = [r for r in by_name["Splash Card"]["reasons"] if r.startswith("splash_off_color_penalty")]
|
||||
assert splash_reasons, by_name["Splash Card"]["reasons"]
|
||||
# Adaptive variant reason format: splash_off_color_penalty_adaptive:<color_count>:<value>
|
||||
adaptive_reason = next(r for r in splash_reasons if r.startswith("splash_off_color_penalty_adaptive"))
|
||||
parts = adaptive_reason.split(":")
|
||||
assert parts[1] == "4" # commander color count
|
||||
penalty_value = float(parts[2])
|
||||
# With base -0.3 and scale 0.5 expect -0.15 (+/- float rounding)
|
||||
assert abs(penalty_value - (-0.3 * 0.5)) < 1e-6
|
||||
54
code/tests/test_sampling_unit.py
Normal file
54
code/tests/test_sampling_unit.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import os
|
||||
from code.web.services import sampling
|
||||
from code.web.services import card_index
|
||||
|
||||
|
||||
def setup_module(module): # ensure deterministic env weights
|
||||
os.environ.setdefault("RARITY_W_MYTHIC", "1.2")
|
||||
|
||||
|
||||
def test_rarity_diminishing():
|
||||
# Monkeypatch internal index
|
||||
card_index._CARD_INDEX.clear() # type: ignore
|
||||
theme = "Test Theme"
|
||||
card_index._CARD_INDEX[theme] = [ # type: ignore
|
||||
{"name": "Mythic One", "tags": [theme], "color_identity": "G", "mana_cost": "G", "rarity": "mythic"},
|
||||
{"name": "Mythic Two", "tags": [theme], "color_identity": "G", "mana_cost": "G", "rarity": "mythic"},
|
||||
]
|
||||
def no_build():
|
||||
return None
|
||||
sampling.maybe_build_index = no_build # type: ignore
|
||||
cards = sampling.sample_real_cards_for_theme(theme, 2, None, synergies=[theme], commander=None)
|
||||
rarity_weights = [r for c in cards for r in c["reasons"] if r.startswith("rarity_weight_calibrated")] # type: ignore
|
||||
assert len(rarity_weights) >= 2
|
||||
v1 = float(rarity_weights[0].split(":")[-1])
|
||||
v2 = float(rarity_weights[1].split(":")[-1])
|
||||
assert v1 > v2 # diminishing returns
|
||||
|
||||
|
||||
def test_commander_overlap_monotonic_diminishing():
|
||||
cmd_tags = {"A","B","C","D"}
|
||||
synergy_set = {"A","B","C","D","E"}
|
||||
# Build artificial card tag lists with increasing overlaps
|
||||
bonus1 = sampling.commander_overlap_scale(cmd_tags, ["A"], synergy_set)
|
||||
bonus2 = sampling.commander_overlap_scale(cmd_tags, ["A","B"], synergy_set)
|
||||
bonus3 = sampling.commander_overlap_scale(cmd_tags, ["A","B","C"], synergy_set)
|
||||
assert 0 < bonus1 < bonus2 < bonus3
|
||||
# Diminishing increments: delta shrinks
|
||||
assert (bonus2 - bonus1) > 0
|
||||
assert (bonus3 - bonus2) < (bonus2 - bonus1)
|
||||
|
||||
|
||||
def test_splash_off_color_penalty_applied():
|
||||
card_index._CARD_INDEX.clear() # type: ignore
|
||||
theme = "Splash Theme"
|
||||
# Commander W U B R (4 colors)
|
||||
commander = {"name": "CommanderTest", "tags": [theme], "color_identity": "WUBR", "mana_cost": "", "rarity": "mythic"}
|
||||
# Card with single off-color G (W U B R G)
|
||||
splash_card = {"name": "CardSplash", "tags": [theme], "color_identity": "WUBRG", "mana_cost": "G", "rarity": "rare"}
|
||||
card_index._CARD_INDEX[theme] = [commander, splash_card] # type: ignore
|
||||
sampling.maybe_build_index = lambda: None # type: ignore
|
||||
cards = sampling.sample_real_cards_for_theme(theme, 2, None, synergies=[theme], commander="CommanderTest")
|
||||
splash = next((c for c in cards if c["name"] == "CardSplash"), None)
|
||||
assert splash is not None
|
||||
assert any(r.startswith("splash_off_color_penalty") for r in splash["reasons"]) # type: ignore
|
||||
30
code/tests/test_scryfall_name_normalization.py
Normal file
30
code/tests/test_scryfall_name_normalization.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import re
|
||||
from code.web.services.theme_preview import get_theme_preview # type: ignore
|
||||
|
||||
# We can't easily execute the JS normalizeCardName in Python, but we can ensure
|
||||
# server-delivered sample names that include appended synergy annotations are not
|
||||
# leaking into subsequent lookups by simulating the name variant and asserting
|
||||
# normalization logic (mirrors regex in base.html) would strip it.
|
||||
|
||||
NORMALIZE_RE = re.compile(r"(.*?)(\s*-\s*Synergy\s*\(.*\))$", re.IGNORECASE)
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
m = NORMALIZE_RE.match(name)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return name
|
||||
|
||||
|
||||
def test_synergy_annotation_regex_strips_suffix():
|
||||
raw = "Sol Ring - Synergy (Blink Engines)"
|
||||
assert normalize(raw) == "Sol Ring"
|
||||
|
||||
|
||||
def test_preview_sample_names_do_not_contain_synergy_suffix():
|
||||
# Build a preview; sample names might include curated examples but should not
|
||||
# include the synthesized ' - Synergy (' suffix in stored payload.
|
||||
pv = get_theme_preview('Blink', limit=12)
|
||||
for it in pv.get('sample', []):
|
||||
name = it.get('name','')
|
||||
# Ensure regex would not change valid names; if it would, that's a leak.
|
||||
assert normalize(name) == name, f"Name leaked synergy annotation: {name}"
|
||||
34
code/tests/test_service_worker_offline.py
Normal file
34
code/tests/test_service_worker_offline.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import os
|
||||
import importlib
|
||||
import types
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
fastapi = pytest.importorskip("fastapi") # skip if FastAPI missing
|
||||
|
||||
|
||||
def load_app_with_env(**env: str) -> types.ModuleType:
|
||||
for k, v in env.items():
|
||||
os.environ[k] = v
|
||||
import code.web.app as app_module # type: ignore
|
||||
importlib.reload(app_module)
|
||||
return app_module
|
||||
|
||||
|
||||
def test_catalog_hash_exposed_in_template():
|
||||
app_module = load_app_with_env(ENABLE_PWA="1")
|
||||
client = TestClient(app_module.app)
|
||||
r = client.get("/themes/") # picker page should exist
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
# catalog_hash may be 'dev' if not present, ensure variable substituted in SW registration block
|
||||
assert "serviceWorker" in body
|
||||
assert "sw.js?v=" in body
|
||||
|
||||
|
||||
def test_sw_js_served_and_version_param_cache_headers():
|
||||
app_module = load_app_with_env(ENABLE_PWA="1")
|
||||
client = TestClient(app_module.app)
|
||||
r = client.get("/static/sw.js?v=testhash123")
|
||||
assert r.status_code == 200
|
||||
assert "Service Worker" in r.text
|
||||
|
|
@ -69,4 +69,7 @@ def test_warm_index_latency_reduction():
|
|||
get_theme_preview('Blink', limit=6)
|
||||
warm = time.time() - t1
|
||||
# Warm path should generally be faster; allow flakiness with generous factor
|
||||
# If cold time is extremely small (timer resolution), skip strict assertion
|
||||
if cold < 0.0005: # <0.5ms treat as indistinguishable; skip to avoid flaky failure
|
||||
return
|
||||
assert warm <= cold * 1.2, f"Expected warm path faster or near equal (cold={cold}, warm={warm})"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import logging
|
|||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.middleware.gzip import GZipMiddleware
|
||||
from typing import Any
|
||||
from contextlib import asynccontextmanager
|
||||
from .services.combo_utils import detect_all as _detect_all
|
||||
from .services.theme_catalog_loader import prewarm_common_filters # type: ignore
|
||||
|
||||
|
|
@ -21,9 +22,6 @@ _THIS_DIR = Path(__file__).resolve().parent
|
|||
_TEMPLATES_DIR = _THIS_DIR / "templates"
|
||||
_STATIC_DIR = _THIS_DIR / "static"
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue
|
||||
"""FastAPI lifespan context replacing deprecated on_event startup hooks.
|
||||
|
|
@ -39,10 +37,10 @@ async def _lifespan(app: FastAPI): # pragma: no cover - simple infra glue
|
|||
prewarm_common_filters()
|
||||
except Exception:
|
||||
pass
|
||||
# Warm preview card index once
|
||||
# Warm preview card index once (updated Phase A: moved to card_index module)
|
||||
try: # local import to avoid cost if preview unused
|
||||
from .services import theme_preview as _tp # type: ignore
|
||||
_tp._maybe_build_card_index() # internal warm function
|
||||
from .services.card_index import maybe_build_index # type: ignore
|
||||
maybe_build_index()
|
||||
except Exception:
|
||||
pass
|
||||
yield # (no shutdown tasks currently)
|
||||
|
|
@ -143,6 +141,22 @@ templates.env.globals.update({
|
|||
"theme_picker_diagnostics": THEME_PICKER_DIAGNOSTICS,
|
||||
})
|
||||
|
||||
# Expose catalog hash (for cache versioning / service worker) – best-effort, fallback to 'dev'
|
||||
def _load_catalog_hash() -> str:
|
||||
try: # local import to avoid circular on early load
|
||||
from .services.theme_catalog_loader import CATALOG_JSON # type: ignore
|
||||
if CATALOG_JSON.exists():
|
||||
raw = _json.loads(CATALOG_JSON.read_text(encoding="utf-8") or "{}")
|
||||
meta = raw.get("metadata_info") or {}
|
||||
ch = meta.get("catalog_hash") or "dev"
|
||||
if isinstance(ch, str) and ch:
|
||||
return ch[:64]
|
||||
except Exception:
|
||||
pass
|
||||
return "dev"
|
||||
|
||||
templates.env.globals["catalog_hash"] = _load_catalog_hash()
|
||||
|
||||
# --- Simple fragment cache for template partials (low-risk, TTL-based) ---
|
||||
_FRAGMENT_CACHE: dict[tuple[str, str], tuple[float, str]] = {}
|
||||
_FRAGMENT_TTL_SECONDS = 60.0
|
||||
|
|
|
|||
|
|
@ -826,6 +826,46 @@ async def export_preview_csv(
|
|||
return Response(content=csv_text, media_type="text/csv", headers=headers)
|
||||
|
||||
|
||||
# --- Export preview as deck seed (lightweight) ---
|
||||
@router.get("/preview/{theme_id}/export_seed.json")
|
||||
async def export_preview_seed(
|
||||
theme_id: str,
|
||||
limit: int = Query(12, ge=1, le=60),
|
||||
colors: str | None = None,
|
||||
commander: str | None = None,
|
||||
curated_only: bool | None = Query(False, description="If true, only curated example + curated synergy entries influence seed list"),
|
||||
):
|
||||
"""Return a minimal structure usable to bootstrap a deck build flow.
|
||||
|
||||
Output:
|
||||
theme_id, theme, commander (if any), cards (list of names), curated (subset), generated_at.
|
||||
"""
|
||||
try:
|
||||
payload = get_theme_preview(theme_id, limit=limit, colors=colors, commander=commander)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="theme_not_found")
|
||||
items = payload.get("sample", [])
|
||||
def _is_curated(it: dict) -> bool:
|
||||
roles = it.get("roles") or []
|
||||
return any(r in {"example","curated_synergy"} for r in roles)
|
||||
if curated_only:
|
||||
items = [i for i in items if _is_curated(i)]
|
||||
card_names = [i.get("name") for i in items if i.get("name") and not i.get("name").startswith("[")]
|
||||
curated_names = [i.get("name") for i in items if _is_curated(i) and i.get("name")] # exclude synthetic placeholders
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"theme": payload.get("theme"),
|
||||
"theme_id": payload.get("theme_id"),
|
||||
"commander": commander,
|
||||
"limit": limit,
|
||||
"curated_only": bool(curated_only),
|
||||
"generated_at": payload.get("generated_at"),
|
||||
"count": len(card_names),
|
||||
"cards": card_names,
|
||||
"curated": curated_names,
|
||||
})
|
||||
|
||||
|
||||
# --- New: Client performance marks ingestion (Section E) ---
|
||||
@router.post("/metrics/client")
|
||||
async def ingest_client_metrics(request: Request, payload: dict[str, Any] = Body(...)):
|
||||
|
|
|
|||
137
code/web/services/card_index.py
Normal file
137
code/web/services/card_index.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""Card index construction & lookup (extracted from sampling / theme_preview).
|
||||
|
||||
Phase A refactor: Provides a thin API for building and querying the in-memory
|
||||
card index keyed by tag/theme. Future enhancements may introduce a persistent
|
||||
cache layer or precomputed artifact.
|
||||
|
||||
Public API:
|
||||
maybe_build_index() -> None
|
||||
get_tag_pool(tag: str) -> list[dict]
|
||||
lookup_commander(name: str) -> dict | None
|
||||
|
||||
The index is rebuilt lazily when any of the CSV shard files change mtime.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import csv
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
CARD_FILES_GLOB = [
|
||||
Path("csv_files/blue_cards.csv"),
|
||||
Path("csv_files/white_cards.csv"),
|
||||
Path("csv_files/black_cards.csv"),
|
||||
Path("csv_files/red_cards.csv"),
|
||||
Path("csv_files/green_cards.csv"),
|
||||
Path("csv_files/colorless_cards.csv"),
|
||||
Path("csv_files/cards.csv"), # fallback large file last
|
||||
]
|
||||
|
||||
THEME_TAGS_COL = "themeTags"
|
||||
NAME_COL = "name"
|
||||
COLOR_IDENTITY_COL = "colorIdentity"
|
||||
MANA_COST_COL = "manaCost"
|
||||
RARITY_COL = "rarity"
|
||||
|
||||
_CARD_INDEX: Dict[str, List[Dict[str, Any]]] = {}
|
||||
_CARD_INDEX_MTIME: float | None = None
|
||||
|
||||
_RARITY_NORM = {
|
||||
"mythic rare": "mythic",
|
||||
"mythic": "mythic",
|
||||
"m": "mythic",
|
||||
"rare": "rare",
|
||||
"r": "rare",
|
||||
"uncommon": "uncommon",
|
||||
"u": "uncommon",
|
||||
"common": "common",
|
||||
"c": "common",
|
||||
}
|
||||
|
||||
def _normalize_rarity(raw: str) -> str:
|
||||
r = (raw or "").strip().lower()
|
||||
return _RARITY_NORM.get(r, r)
|
||||
|
||||
def _resolve_card_files() -> List[Path]:
|
||||
"""Return base card file list + any extra test files supplied via env.
|
||||
|
||||
Environment variable: CARD_INDEX_EXTRA_CSV can contain a comma or semicolon
|
||||
separated list of additional CSV paths (used by tests to inject synthetic
|
||||
edge cases without polluting production shards).
|
||||
"""
|
||||
files: List[Path] = list(CARD_FILES_GLOB)
|
||||
extra = os.getenv("CARD_INDEX_EXTRA_CSV")
|
||||
if extra:
|
||||
for part in extra.replace(";", ",").split(","):
|
||||
p = part.strip()
|
||||
if not p:
|
||||
continue
|
||||
path_obj = Path(p)
|
||||
# Include even if missing; maybe created later in test before build
|
||||
files.append(path_obj)
|
||||
return files
|
||||
|
||||
|
||||
def maybe_build_index() -> None:
|
||||
"""Rebuild the index if any card CSV mtime changed.
|
||||
|
||||
Incorporates any extra CSVs specified via CARD_INDEX_EXTRA_CSV.
|
||||
"""
|
||||
global _CARD_INDEX, _CARD_INDEX_MTIME
|
||||
latest = 0.0
|
||||
card_files = _resolve_card_files()
|
||||
for p in card_files:
|
||||
if p.exists():
|
||||
mt = p.stat().st_mtime
|
||||
if mt > latest:
|
||||
latest = mt
|
||||
if _CARD_INDEX and _CARD_INDEX_MTIME and latest <= _CARD_INDEX_MTIME:
|
||||
return
|
||||
new_index: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for p in card_files:
|
||||
if not p.exists():
|
||||
continue
|
||||
try:
|
||||
with p.open("r", encoding="utf-8", newline="") as fh:
|
||||
reader = csv.DictReader(fh)
|
||||
if not reader.fieldnames or THEME_TAGS_COL not in reader.fieldnames:
|
||||
continue
|
||||
for row in reader:
|
||||
name = row.get(NAME_COL) or row.get("faceName") or ""
|
||||
tags_raw = row.get(THEME_TAGS_COL) or ""
|
||||
tags = [t.strip(" '[]") for t in tags_raw.split(',') if t.strip()] if tags_raw else []
|
||||
if not tags:
|
||||
continue
|
||||
color_id = (row.get(COLOR_IDENTITY_COL) or "").strip()
|
||||
mana_cost = (row.get(MANA_COST_COL) or "").strip()
|
||||
rarity = _normalize_rarity(row.get(RARITY_COL) or "")
|
||||
for tg in tags:
|
||||
if not tg:
|
||||
continue
|
||||
new_index.setdefault(tg, []).append({
|
||||
"name": name,
|
||||
"color_identity": color_id,
|
||||
"tags": tags,
|
||||
"mana_cost": mana_cost,
|
||||
"rarity": rarity,
|
||||
"color_identity_list": list(color_id) if color_id else [],
|
||||
"pip_colors": [c for c in mana_cost if c in {"W","U","B","R","G"}],
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
_CARD_INDEX = new_index
|
||||
_CARD_INDEX_MTIME = latest
|
||||
|
||||
def get_tag_pool(tag: str) -> List[Dict[str, Any]]:
|
||||
return _CARD_INDEX.get(tag, [])
|
||||
|
||||
def lookup_commander(name: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
if not name:
|
||||
return None
|
||||
needle = name.lower().strip()
|
||||
for tag_cards in _CARD_INDEX.values():
|
||||
for c in tag_cards:
|
||||
if c.get("name", "").lower() == needle:
|
||||
return c
|
||||
return None
|
||||
323
code/web/services/preview_cache.py
Normal file
323
code/web/services/preview_cache.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
"""Preview cache utilities & adaptive policy (Core Refactor Phase A continued).
|
||||
|
||||
This module now owns:
|
||||
- In-memory preview cache (OrderedDict)
|
||||
- Cache bust helper
|
||||
- Adaptive TTL policy & recent hit tracking
|
||||
- Background refresh thread orchestration (warming top-K hot themes)
|
||||
|
||||
`theme_preview` orchestrator invokes `record_request_hit()` and
|
||||
`maybe_adapt_ttl()` after each build/cache check, and calls `ensure_bg_thread()`
|
||||
post-build. Metrics still aggregated in `theme_preview` but TTL state lives
|
||||
here to prepare for future backend abstraction.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict, deque
|
||||
from typing import Any, Dict, Tuple, Callable
|
||||
import time as _t
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
import math
|
||||
|
||||
from .preview_metrics import record_eviction # type: ignore
|
||||
|
||||
# Phase 2 extraction: adaptive TTL band policy moved into preview_policy
|
||||
from .preview_policy import (
|
||||
compute_ttl_adjustment,
|
||||
DEFAULT_TTL_BASE as _POLICY_TTL_BASE,
|
||||
DEFAULT_TTL_MIN as _POLICY_TTL_MIN,
|
||||
DEFAULT_TTL_MAX as _POLICY_TTL_MAX,
|
||||
)
|
||||
from .preview_cache_backend import redis_store # type: ignore
|
||||
|
||||
TTL_SECONDS = 600
|
||||
# Backward-compat variable names retained (tests may reference) mapping to policy constants
|
||||
_TTL_BASE = _POLICY_TTL_BASE
|
||||
_TTL_MIN = _POLICY_TTL_MIN
|
||||
_TTL_MAX = _POLICY_TTL_MAX
|
||||
_ADAPT_SAMPLE_WINDOW = 120
|
||||
_ADAPT_INTERVAL_S = 30
|
||||
_ADAPTATION_ENABLED = (os.getenv("THEME_PREVIEW_ADAPTIVE") or "").lower() in {"1","true","yes","on"}
|
||||
_RECENT_HITS: "deque[bool]" = deque(maxlen=_ADAPT_SAMPLE_WINDOW)
|
||||
_LAST_ADAPT_AT: float | None = None
|
||||
|
||||
_BG_REFRESH_THREAD_STARTED = False
|
||||
_BG_REFRESH_INTERVAL_S = int(os.getenv("THEME_PREVIEW_BG_REFRESH_INTERVAL") or 120)
|
||||
_BG_REFRESH_ENABLED = (os.getenv("THEME_PREVIEW_BG_REFRESH") or "").lower() in {"1","true","yes","on"}
|
||||
_BG_REFRESH_MIN = 30
|
||||
_BG_REFRESH_MAX = max(300, _BG_REFRESH_INTERVAL_S * 5)
|
||||
|
||||
def record_request_hit(hit: bool) -> None:
|
||||
_RECENT_HITS.append(hit)
|
||||
|
||||
def recent_hit_window() -> int:
|
||||
return len(_RECENT_HITS)
|
||||
|
||||
def ttl_seconds() -> int:
|
||||
return TTL_SECONDS
|
||||
|
||||
def _maybe_adapt_ttl(now: float) -> None:
|
||||
"""Apply adaptive TTL adjustment using extracted policy.
|
||||
|
||||
Keeps prior guards (sample window, interval) for stability; only the
|
||||
banded adjustment math has moved to preview_policy.
|
||||
"""
|
||||
global TTL_SECONDS, _LAST_ADAPT_AT
|
||||
if not _ADAPTATION_ENABLED:
|
||||
return
|
||||
if len(_RECENT_HITS) < max(30, int(_ADAPT_SAMPLE_WINDOW * 0.5)):
|
||||
return
|
||||
if _LAST_ADAPT_AT and (now - _LAST_ADAPT_AT) < _ADAPT_INTERVAL_S:
|
||||
return
|
||||
hit_ratio = sum(1 for h in _RECENT_HITS if h) / len(_RECENT_HITS)
|
||||
new_ttl = compute_ttl_adjustment(hit_ratio, TTL_SECONDS, _TTL_BASE, _TTL_MIN, _TTL_MAX)
|
||||
if new_ttl != TTL_SECONDS:
|
||||
TTL_SECONDS = new_ttl
|
||||
try: # pragma: no cover - defensive logging
|
||||
print(json.dumps({
|
||||
"event": "theme_preview_ttl_adapt",
|
||||
"hit_ratio": round(hit_ratio, 3),
|
||||
"ttl": TTL_SECONDS,
|
||||
})) # noqa: T201
|
||||
except Exception:
|
||||
pass
|
||||
_LAST_ADAPT_AT = now
|
||||
|
||||
def maybe_adapt_ttl() -> None:
|
||||
_maybe_adapt_ttl(_t.time())
|
||||
|
||||
def _bg_refresh_loop(build_top_slug: Callable[[str], None], get_hot_slugs: Callable[[], list[str]]): # pragma: no cover
|
||||
while True:
|
||||
if not _BG_REFRESH_ENABLED:
|
||||
return
|
||||
try:
|
||||
for slug in get_hot_slugs():
|
||||
try:
|
||||
build_top_slug(slug)
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
_t.sleep(_BG_REFRESH_INTERVAL_S)
|
||||
|
||||
def ensure_bg_thread(build_top_slug: Callable[[str], None], get_hot_slugs: Callable[[], list[str]]): # pragma: no cover
|
||||
global _BG_REFRESH_THREAD_STARTED
|
||||
if _BG_REFRESH_THREAD_STARTED or not _BG_REFRESH_ENABLED:
|
||||
return
|
||||
try:
|
||||
th = threading.Thread(target=_bg_refresh_loop, args=(build_top_slug, get_hot_slugs), name="theme_preview_bg_refresh", daemon=True)
|
||||
th.start()
|
||||
_BG_REFRESH_THREAD_STARTED = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
PREVIEW_CACHE: "OrderedDict[Tuple[str, int, str | None, str | None, str], Dict[str, Any]]" = OrderedDict()
|
||||
# Cache entry shape (dict) — groundwork for adaptive eviction (Phase 2)
|
||||
# Keys:
|
||||
# payload: preview payload dict
|
||||
# _cached_at / cached_at: epoch seconds when stored (TTL reference; _cached_at kept for backward compat)
|
||||
# inserted_at: epoch seconds first insertion
|
||||
# last_access: epoch seconds of last successful cache hit
|
||||
# hit_count: int number of cache hits (excludes initial store)
|
||||
# build_cost_ms: float build duration captured at store time (used for cost-based protection)
|
||||
|
||||
def register_cache_hit(key: Tuple[str, int, str | None, str | None, str]) -> None:
|
||||
entry = PREVIEW_CACHE.get(key)
|
||||
if not entry:
|
||||
return
|
||||
now = _t.time()
|
||||
# Initialize metadata if legacy entry present
|
||||
if "inserted_at" not in entry:
|
||||
entry["inserted_at"] = entry.get("_cached_at", now)
|
||||
entry["last_access"] = now
|
||||
entry["hit_count"] = int(entry.get("hit_count", 0)) + 1
|
||||
|
||||
def store_cache_entry(key: Tuple[str, int, str | None, str | None, str], payload: Dict[str, Any], build_cost_ms: float) -> None:
|
||||
now = _t.time()
|
||||
PREVIEW_CACHE[key] = {
|
||||
"payload": payload,
|
||||
"_cached_at": now, # legacy field name
|
||||
"cached_at": now,
|
||||
"inserted_at": now,
|
||||
"last_access": now,
|
||||
"hit_count": 0,
|
||||
"build_cost_ms": float(build_cost_ms),
|
||||
}
|
||||
PREVIEW_CACHE.move_to_end(key)
|
||||
# Optional Redis write-through (best-effort)
|
||||
try:
|
||||
if os.getenv("THEME_PREVIEW_REDIS_URL") and not os.getenv("THEME_PREVIEW_REDIS_DISABLE"):
|
||||
redis_store(key, payload, int(TTL_SECONDS), build_cost_ms)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- Adaptive Eviction Weight & Threshold Resolution (Phase 2 Step 4) --- #
|
||||
_EVICT_WEIGHTS_CACHE: Dict[str, float] | None = None
|
||||
_EVICT_THRESH_CACHE: Tuple[float, float, float] | None = None
|
||||
|
||||
def _resolve_eviction_weights() -> Dict[str, float]:
|
||||
global _EVICT_WEIGHTS_CACHE
|
||||
if _EVICT_WEIGHTS_CACHE is not None:
|
||||
return _EVICT_WEIGHTS_CACHE
|
||||
def _f(env_key: str, default: float) -> float:
|
||||
raw = os.getenv(env_key)
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return float(raw)
|
||||
except Exception:
|
||||
return default
|
||||
_EVICT_WEIGHTS_CACHE = {
|
||||
"W_HITS": _f("THEME_PREVIEW_EVICT_W_HITS", 3.0),
|
||||
"W_RECENCY": _f("THEME_PREVIEW_EVICT_W_RECENCY", 2.0),
|
||||
"W_COST": _f("THEME_PREVIEW_EVICT_W_COST", 1.0),
|
||||
"W_AGE": _f("THEME_PREVIEW_EVICT_W_AGE", 1.5),
|
||||
}
|
||||
return _EVICT_WEIGHTS_CACHE
|
||||
|
||||
def _resolve_cost_thresholds() -> Tuple[float, float, float]:
|
||||
global _EVICT_THRESH_CACHE
|
||||
if _EVICT_THRESH_CACHE is not None:
|
||||
return _EVICT_THRESH_CACHE
|
||||
raw = os.getenv("THEME_PREVIEW_EVICT_COST_THRESHOLDS", "5,15,40")
|
||||
parts = [p.strip() for p in raw.split(',') if p.strip()]
|
||||
nums: list[float] = []
|
||||
for p in parts:
|
||||
try:
|
||||
nums.append(float(p))
|
||||
except Exception:
|
||||
pass
|
||||
while len(nums) < 3:
|
||||
# pad with defaults if insufficient
|
||||
defaults = [5.0, 15.0, 40.0]
|
||||
nums.append(defaults[len(nums)])
|
||||
nums = sorted(nums[:3])
|
||||
_EVICT_THRESH_CACHE = (nums[0], nums[1], nums[2])
|
||||
return _EVICT_THRESH_CACHE
|
||||
|
||||
def _cost_bucket(build_cost_ms: float) -> int:
|
||||
t1, t2, t3 = _resolve_cost_thresholds()
|
||||
if build_cost_ms < t1:
|
||||
return 0
|
||||
if build_cost_ms < t2:
|
||||
return 1
|
||||
if build_cost_ms < t3:
|
||||
return 2
|
||||
return 3
|
||||
|
||||
def compute_protection_score(entry: Dict[str, Any], now: float | None = None) -> float:
|
||||
"""Compute protection score (higher = more protected from eviction).
|
||||
|
||||
Score components:
|
||||
- hit_count (log scaled) weighted by W_HITS
|
||||
- recency (inverse minutes since last access) weighted by W_RECENCY
|
||||
- build cost bucket weighted by W_COST
|
||||
- age penalty (minutes since insert) weighted by W_AGE (subtracted)
|
||||
"""
|
||||
if now is None:
|
||||
now = _t.time()
|
||||
weights = _resolve_eviction_weights()
|
||||
inserted = float(entry.get("inserted_at", now))
|
||||
last_access = float(entry.get("last_access", inserted))
|
||||
hits = int(entry.get("hit_count", 0))
|
||||
build_cost_ms = float(entry.get("build_cost_ms", 0.0))
|
||||
minutes_since_last = max(0.0, (now - last_access) / 60.0)
|
||||
minutes_since_insert = max(0.0, (now - inserted) / 60.0)
|
||||
recency_score = 1.0 / (1.0 + minutes_since_last)
|
||||
age_score = minutes_since_insert
|
||||
cost_b = _cost_bucket(build_cost_ms)
|
||||
score = (
|
||||
weights["W_HITS"] * math.log(1 + hits)
|
||||
+ weights["W_RECENCY"] * recency_score
|
||||
+ weights["W_COST"] * cost_b
|
||||
- weights["W_AGE"] * age_score
|
||||
)
|
||||
return float(score)
|
||||
|
||||
# --- Eviction Logic (Phase 2 Step 6) --- #
|
||||
def _cache_max() -> int:
|
||||
try:
|
||||
raw = os.getenv("THEME_PREVIEW_CACHE_MAX") or "400"
|
||||
v = int(raw)
|
||||
if v <= 0:
|
||||
raise ValueError
|
||||
return v
|
||||
except Exception:
|
||||
return 400
|
||||
|
||||
def evict_if_needed() -> None:
|
||||
"""Adaptive eviction replacing FIFO.
|
||||
|
||||
Strategy:
|
||||
- If size <= limit: no-op
|
||||
- If size > 2*limit: emergency overflow path (age-based removal until within limit)
|
||||
- Else: remove lowest protection score entry (single) if over limit
|
||||
"""
|
||||
try:
|
||||
# Removed previous hard floor (50) to allow test scenarios with small limits.
|
||||
# Operational deployments can still set higher env value. Tests rely on low limits
|
||||
# (e.g., 5) to exercise eviction deterministically.
|
||||
limit = _cache_max()
|
||||
size = len(PREVIEW_CACHE)
|
||||
if size <= limit:
|
||||
return
|
||||
now = _t.time()
|
||||
# Emergency overflow path
|
||||
if size > 2 * limit:
|
||||
while len(PREVIEW_CACHE) > limit:
|
||||
# Oldest by inserted_at/_cached_at
|
||||
oldest_key = min(
|
||||
PREVIEW_CACHE.items(),
|
||||
key=lambda kv: kv[1].get("inserted_at", kv[1].get("_cached_at", 0.0)),
|
||||
)[0]
|
||||
entry = PREVIEW_CACHE.pop(oldest_key)
|
||||
meta = {
|
||||
"hit_count": int(entry.get("hit_count", 0)),
|
||||
"age_ms": int((now - entry.get("inserted_at", now)) * 1000),
|
||||
"build_cost_ms": float(entry.get("build_cost_ms", 0.0)),
|
||||
"protection_score": compute_protection_score(entry, now),
|
||||
"reason": "emergency_overflow",
|
||||
"cache_limit": limit,
|
||||
"size_before": size,
|
||||
"size_after": len(PREVIEW_CACHE),
|
||||
}
|
||||
record_eviction(meta)
|
||||
return
|
||||
# Standard single-entry score-based eviction
|
||||
lowest_key = None
|
||||
lowest_score = None
|
||||
for key, entry in PREVIEW_CACHE.items():
|
||||
score = compute_protection_score(entry, now)
|
||||
if lowest_score is None or score < lowest_score:
|
||||
lowest_key = key
|
||||
lowest_score = score
|
||||
if lowest_key is not None:
|
||||
entry = PREVIEW_CACHE.pop(lowest_key)
|
||||
meta = {
|
||||
"hit_count": int(entry.get("hit_count", 0)),
|
||||
"age_ms": int((now - entry.get("inserted_at", now)) * 1000),
|
||||
"build_cost_ms": float(entry.get("build_cost_ms", 0.0)),
|
||||
"protection_score": float(lowest_score if lowest_score is not None else 0.0),
|
||||
"reason": "low_score",
|
||||
"cache_limit": limit,
|
||||
"size_before": size,
|
||||
"size_after": len(PREVIEW_CACHE),
|
||||
}
|
||||
record_eviction(meta)
|
||||
except Exception:
|
||||
# Fail quiet; eviction is best-effort
|
||||
pass
|
||||
_PREVIEW_LAST_BUST_AT: float | None = None
|
||||
|
||||
def bust_preview_cache(reason: str | None = None) -> None: # pragma: no cover (trivial)
|
||||
global PREVIEW_CACHE, _PREVIEW_LAST_BUST_AT
|
||||
try:
|
||||
PREVIEW_CACHE.clear()
|
||||
_PREVIEW_LAST_BUST_AT = _t.time()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def preview_cache_last_bust_at() -> float | None:
|
||||
return _PREVIEW_LAST_BUST_AT
|
||||
113
code/web/services/preview_cache_backend.py
Normal file
113
code/web/services/preview_cache_backend.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Cache backend abstraction (Phase 2 extension) with Redis PoC.
|
||||
|
||||
The in-memory cache remains authoritative for adaptive eviction heuristics.
|
||||
This backend layer provides optional read-through / write-through to Redis
|
||||
for latency & CPU comparison. It is intentionally minimal:
|
||||
|
||||
Environment:
|
||||
THEME_PREVIEW_REDIS_URL=redis://host:port/db -> enable PoC if redis-py importable
|
||||
THEME_PREVIEW_REDIS_DISABLE=1 -> hard disable even if URL present
|
||||
|
||||
Behavior:
|
||||
- On store: serialize payload + metadata into JSON and SETEX with TTL.
|
||||
- On get (memory miss only): attempt Redis GET and rehydrate (respect TTL).
|
||||
- Failures are swallowed; metrics track attempts/hits/errors.
|
||||
|
||||
No eviction coordination is attempted; Redis TTL handles expiry. The goal is
|
||||
purely observational at this stage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
try: # lazy optional dependency
|
||||
import redis # type: ignore
|
||||
except Exception: # pragma: no cover - absence path
|
||||
redis = None # type: ignore
|
||||
|
||||
_URL = os.getenv("THEME_PREVIEW_REDIS_URL")
|
||||
_DISABLED = (os.getenv("THEME_PREVIEW_REDIS_DISABLE") or "").lower() in {"1","true","yes","on"}
|
||||
|
||||
_CLIENT = None
|
||||
_INIT_ERR: str | None = None
|
||||
|
||||
def _init() -> None:
|
||||
global _CLIENT, _INIT_ERR
|
||||
if _CLIENT is not None or _INIT_ERR is not None:
|
||||
return
|
||||
if _DISABLED or not _URL or not redis:
|
||||
_INIT_ERR = "disabled_or_missing"
|
||||
return
|
||||
try:
|
||||
_CLIENT = redis.Redis.from_url(_URL, socket_timeout=0.25) # type: ignore
|
||||
# lightweight ping (non-fatal)
|
||||
try:
|
||||
_CLIENT.ping()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e: # pragma: no cover - network/dep issues
|
||||
_INIT_ERR = f"init_error:{e}"[:120]
|
||||
|
||||
|
||||
def backend_info() -> Dict[str, Any]:
|
||||
return {
|
||||
"enabled": bool(_CLIENT),
|
||||
"init_error": _INIT_ERR,
|
||||
"url_present": bool(_URL),
|
||||
}
|
||||
|
||||
def _serialize(key: Tuple[str, int, str | None, str | None, str], payload: Dict[str, Any], build_cost_ms: float) -> str:
|
||||
return json.dumps({
|
||||
"k": list(key),
|
||||
"p": payload,
|
||||
"bc": build_cost_ms,
|
||||
"ts": time.time(),
|
||||
}, separators=(",", ":"))
|
||||
|
||||
def redis_store(key: Tuple[str, int, str | None, str | None, str], payload: Dict[str, Any], ttl_seconds: int, build_cost_ms: float) -> bool:
|
||||
_init()
|
||||
if not _CLIENT:
|
||||
return False
|
||||
try:
|
||||
data = _serialize(key, payload, build_cost_ms)
|
||||
# Compose a simple namespaced key; join tuple parts with '|'
|
||||
skey = "tpv:" + "|".join([str(part) for part in key])
|
||||
_CLIENT.setex(skey, ttl_seconds, data)
|
||||
return True
|
||||
except Exception: # pragma: no cover
|
||||
return False
|
||||
|
||||
def redis_get(key: Tuple[str, int, str | None, str | None, str]) -> Optional[Dict[str, Any]]:
|
||||
_init()
|
||||
if not _CLIENT:
|
||||
return None
|
||||
try:
|
||||
skey = "tpv:" + "|".join([str(part) for part in key])
|
||||
raw: bytes | None = _CLIENT.get(skey) # type: ignore
|
||||
if not raw:
|
||||
return None
|
||||
obj = json.loads(raw.decode("utf-8"))
|
||||
# Expect shape from _serialize
|
||||
payload = obj.get("p")
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
return {
|
||||
"payload": payload,
|
||||
"_cached_at": float(obj.get("ts") or 0),
|
||||
"cached_at": float(obj.get("ts") or 0),
|
||||
"inserted_at": float(obj.get("ts") or 0),
|
||||
"last_access": float(obj.get("ts") or 0),
|
||||
"hit_count": 0,
|
||||
"build_cost_ms": float(obj.get("bc") or 0.0),
|
||||
}
|
||||
except Exception: # pragma: no cover
|
||||
return None
|
||||
|
||||
__all__ = [
|
||||
"backend_info",
|
||||
"redis_store",
|
||||
"redis_get",
|
||||
]
|
||||
285
code/web/services/preview_metrics.py
Normal file
285
code/web/services/preview_metrics.py
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"""Metrics aggregation for theme preview service.
|
||||
|
||||
Extracted from `theme_preview.py` (Phase 2 refactor) to isolate
|
||||
metrics/state reporting from orchestration & caching logic. This allows
|
||||
future experimentation with alternative cache backends / eviction without
|
||||
coupling metrics concerns.
|
||||
|
||||
Public API:
|
||||
record_build_duration(ms: float)
|
||||
record_role_counts(role_counts: dict[str,int])
|
||||
record_curated_sampled(curated: int, sampled: int)
|
||||
record_per_theme(slug: str, build_ms: float, curated: int, sampled: int)
|
||||
record_request(hit: bool, error: bool = False, client_error: bool = False)
|
||||
record_per_theme_error(slug: str)
|
||||
preview_metrics() -> dict
|
||||
|
||||
The consuming orchestrator remains responsible for calling these hooks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
import os
|
||||
|
||||
# Global counters (mirrors previous names for backward compatibility where tests may introspect)
|
||||
_PREVIEW_BUILD_MS_TOTAL = 0.0
|
||||
_PREVIEW_BUILD_COUNT = 0
|
||||
_BUILD_DURATIONS: List[float] = []
|
||||
_ROLE_GLOBAL_COUNTS: dict[str, int] = {}
|
||||
_CURATED_GLOBAL = 0
|
||||
_SAMPLED_GLOBAL = 0
|
||||
_PREVIEW_PER_THEME: dict[str, Dict[str, Any]] = {}
|
||||
_PREVIEW_PER_THEME_REQUESTS: dict[str, int] = {}
|
||||
_PREVIEW_PER_THEME_ERRORS: dict[str, int] = {}
|
||||
_PREVIEW_REQUESTS = 0
|
||||
_PREVIEW_CACHE_HITS = 0
|
||||
_PREVIEW_ERROR_COUNT = 0
|
||||
_PREVIEW_REQUEST_ERROR_COUNT = 0
|
||||
_EVICTION_TOTAL = 0
|
||||
_EVICTION_BY_REASON: dict[str, int] = {}
|
||||
_EVICTION_LAST: dict[str, Any] | None = None
|
||||
_SPLASH_OFF_COLOR_TOTAL = 0
|
||||
_SPLASH_PREVIEWS_WITH_PENALTY = 0
|
||||
_SPLASH_PENALTY_CARD_EVENTS = 0
|
||||
_REDIS_GET_ATTEMPTS = 0
|
||||
_REDIS_GET_HITS = 0
|
||||
_REDIS_GET_ERRORS = 0
|
||||
_REDIS_STORE_ATTEMPTS = 0
|
||||
_REDIS_STORE_ERRORS = 0
|
||||
|
||||
def record_redis_get(hit: bool, error: bool = False):
|
||||
global _REDIS_GET_ATTEMPTS, _REDIS_GET_HITS, _REDIS_GET_ERRORS
|
||||
_REDIS_GET_ATTEMPTS += 1
|
||||
if hit:
|
||||
_REDIS_GET_HITS += 1
|
||||
if error:
|
||||
_REDIS_GET_ERRORS += 1
|
||||
|
||||
def record_redis_store(error: bool = False):
|
||||
global _REDIS_STORE_ATTEMPTS, _REDIS_STORE_ERRORS
|
||||
_REDIS_STORE_ATTEMPTS += 1
|
||||
if error:
|
||||
_REDIS_STORE_ERRORS += 1
|
||||
|
||||
# External state accessors (injected via set functions) to avoid import cycle
|
||||
_ttl_seconds_fn = None
|
||||
_recent_hit_window_fn = None
|
||||
_cache_len_fn = None
|
||||
_last_bust_at_fn = None
|
||||
_curated_synergy_loaded_fn = None
|
||||
_curated_synergy_size_fn = None
|
||||
|
||||
def configure_external_access(
|
||||
ttl_seconds_fn,
|
||||
recent_hit_window_fn,
|
||||
cache_len_fn,
|
||||
last_bust_at_fn,
|
||||
curated_synergy_loaded_fn,
|
||||
curated_synergy_size_fn,
|
||||
):
|
||||
global _ttl_seconds_fn, _recent_hit_window_fn, _cache_len_fn, _last_bust_at_fn, _curated_synergy_loaded_fn, _curated_synergy_size_fn
|
||||
_ttl_seconds_fn = ttl_seconds_fn
|
||||
_recent_hit_window_fn = recent_hit_window_fn
|
||||
_cache_len_fn = cache_len_fn
|
||||
_last_bust_at_fn = last_bust_at_fn
|
||||
_curated_synergy_loaded_fn = curated_synergy_loaded_fn
|
||||
_curated_synergy_size_fn = curated_synergy_size_fn
|
||||
|
||||
def record_build_duration(ms: float) -> None:
|
||||
global _PREVIEW_BUILD_MS_TOTAL, _PREVIEW_BUILD_COUNT
|
||||
_PREVIEW_BUILD_MS_TOTAL += ms
|
||||
_PREVIEW_BUILD_COUNT += 1
|
||||
_BUILD_DURATIONS.append(ms)
|
||||
|
||||
def record_role_counts(role_counts: Dict[str, int]) -> None:
|
||||
for r, c in role_counts.items():
|
||||
_ROLE_GLOBAL_COUNTS[r] = _ROLE_GLOBAL_COUNTS.get(r, 0) + c
|
||||
|
||||
def record_curated_sampled(curated: int, sampled: int) -> None:
|
||||
global _CURATED_GLOBAL, _SAMPLED_GLOBAL
|
||||
_CURATED_GLOBAL += curated
|
||||
_SAMPLED_GLOBAL += sampled
|
||||
|
||||
def record_per_theme(slug: str, build_ms: float, curated: int, sampled: int) -> None:
|
||||
data = _PREVIEW_PER_THEME.setdefault(slug, {"total_ms": 0.0, "builds": 0, "durations": [], "curated": 0, "sampled": 0})
|
||||
data["total_ms"] += build_ms
|
||||
data["builds"] += 1
|
||||
durs = data["durations"]
|
||||
durs.append(build_ms)
|
||||
if len(durs) > 100:
|
||||
del durs[0: len(durs) - 100]
|
||||
data["curated"] += curated
|
||||
data["sampled"] += sampled
|
||||
|
||||
def record_request(hit: bool, error: bool = False, client_error: bool = False) -> None:
|
||||
global _PREVIEW_REQUESTS, _PREVIEW_CACHE_HITS, _PREVIEW_ERROR_COUNT, _PREVIEW_REQUEST_ERROR_COUNT
|
||||
_PREVIEW_REQUESTS += 1
|
||||
if hit:
|
||||
_PREVIEW_CACHE_HITS += 1
|
||||
if error:
|
||||
_PREVIEW_ERROR_COUNT += 1
|
||||
if client_error:
|
||||
_PREVIEW_REQUEST_ERROR_COUNT += 1
|
||||
|
||||
def record_per_theme_error(slug: str) -> None:
|
||||
_PREVIEW_PER_THEME_ERRORS[slug] = _PREVIEW_PER_THEME_ERRORS.get(slug, 0) + 1
|
||||
|
||||
def _percentile(sorted_vals: List[float], pct: float) -> float:
|
||||
if not sorted_vals:
|
||||
return 0.0
|
||||
k = (len(sorted_vals) - 1) * pct
|
||||
f = int(k)
|
||||
c = min(f + 1, len(sorted_vals) - 1)
|
||||
if f == c:
|
||||
return sorted_vals[f]
|
||||
d0 = sorted_vals[f] * (c - k)
|
||||
d1 = sorted_vals[c] * (k - f)
|
||||
return d0 + d1
|
||||
|
||||
def preview_metrics() -> Dict[str, Any]:
|
||||
ttl_seconds = _ttl_seconds_fn() if _ttl_seconds_fn else 0
|
||||
recent_window = _recent_hit_window_fn() if _recent_hit_window_fn else 0
|
||||
cache_len = _cache_len_fn() if _cache_len_fn else 0
|
||||
last_bust = _last_bust_at_fn() if _last_bust_at_fn else None
|
||||
avg_ms = (_PREVIEW_BUILD_MS_TOTAL / _PREVIEW_BUILD_COUNT) if _PREVIEW_BUILD_COUNT else 0.0
|
||||
durations_list = sorted(list(_BUILD_DURATIONS))
|
||||
p95 = _percentile(durations_list, 0.95)
|
||||
# Role distribution aggregate
|
||||
total_roles = sum(_ROLE_GLOBAL_COUNTS.values()) or 1
|
||||
target = {"payoff": 0.4, "enabler+support": 0.4, "wildcard": 0.2}
|
||||
actual_enabler_support = (_ROLE_GLOBAL_COUNTS.get("enabler", 0) + _ROLE_GLOBAL_COUNTS.get("support", 0)) / total_roles
|
||||
role_distribution = {
|
||||
"payoff": {
|
||||
"count": _ROLE_GLOBAL_COUNTS.get("payoff", 0),
|
||||
"actual_pct": round((_ROLE_GLOBAL_COUNTS.get("payoff", 0) / total_roles) * 100, 2),
|
||||
"target_pct": target["payoff"] * 100,
|
||||
},
|
||||
"enabler_support": {
|
||||
"count": _ROLE_GLOBAL_COUNTS.get("enabler", 0) + _ROLE_GLOBAL_COUNTS.get("support", 0),
|
||||
"actual_pct": round(actual_enabler_support * 100, 2),
|
||||
"target_pct": target["enabler+support"] * 100,
|
||||
},
|
||||
"wildcard": {
|
||||
"count": _ROLE_GLOBAL_COUNTS.get("wildcard", 0),
|
||||
"actual_pct": round((_ROLE_GLOBAL_COUNTS.get("wildcard", 0) / total_roles) * 100, 2),
|
||||
"target_pct": target["wildcard"] * 100,
|
||||
},
|
||||
}
|
||||
editorial_coverage_pct = round((_CURATED_GLOBAL / max(1, (_CURATED_GLOBAL + _SAMPLED_GLOBAL))) * 100, 2)
|
||||
per_theme_stats: Dict[str, Any] = {}
|
||||
for slug, data in list(_PREVIEW_PER_THEME.items())[:50]:
|
||||
durs = list(data.get("durations", []))
|
||||
sd = sorted(durs)
|
||||
p50 = _percentile(sd, 0.50)
|
||||
p95_local = _percentile(sd, 0.95)
|
||||
per_theme_stats[slug] = {
|
||||
"avg_ms": round(data["total_ms"] / max(1, data["builds"]), 2),
|
||||
"p50_ms": round(p50, 2),
|
||||
"p95_ms": round(p95_local, 2),
|
||||
"builds": data["builds"],
|
||||
"avg_curated_pct": round((data["curated"] / max(1, (data["curated"] + data["sampled"])) ) * 100, 2),
|
||||
"requests": _PREVIEW_PER_THEME_REQUESTS.get(slug, 0),
|
||||
"curated_total": data.get("curated", 0),
|
||||
"sampled_total": data.get("sampled", 0),
|
||||
}
|
||||
error_rate = 0.0
|
||||
total_req = _PREVIEW_REQUESTS or 0
|
||||
if total_req:
|
||||
error_rate = round((_PREVIEW_ERROR_COUNT / total_req) * 100, 2)
|
||||
try:
|
||||
enforce_threshold = float(os.getenv("EXAMPLE_ENFORCE_THRESHOLD", "90"))
|
||||
except Exception: # pragma: no cover
|
||||
enforce_threshold = 90.0
|
||||
example_enforcement_active = editorial_coverage_pct >= enforce_threshold
|
||||
curated_synergy_loaded = _curated_synergy_loaded_fn() if _curated_synergy_loaded_fn else False
|
||||
curated_synergy_size = _curated_synergy_size_fn() if _curated_synergy_size_fn else 0
|
||||
return {
|
||||
"preview_requests": _PREVIEW_REQUESTS,
|
||||
"preview_cache_hits": _PREVIEW_CACHE_HITS,
|
||||
"preview_cache_entries": cache_len,
|
||||
"preview_cache_evictions": _EVICTION_TOTAL,
|
||||
"preview_cache_evictions_by_reason": dict(_EVICTION_BY_REASON),
|
||||
"preview_cache_eviction_last": _EVICTION_LAST,
|
||||
"preview_avg_build_ms": round(avg_ms, 2),
|
||||
"preview_p95_build_ms": round(p95, 2),
|
||||
"preview_error_rate_pct": error_rate,
|
||||
"preview_client_fetch_errors": _PREVIEW_REQUEST_ERROR_COUNT,
|
||||
"preview_ttl_seconds": ttl_seconds,
|
||||
"preview_ttl_adaptive": True,
|
||||
"preview_ttl_window": recent_window,
|
||||
"preview_last_bust_at": last_bust,
|
||||
"role_distribution": role_distribution,
|
||||
"editorial_curated_vs_sampled_pct": editorial_coverage_pct,
|
||||
"example_enforcement_active": example_enforcement_active,
|
||||
"example_enforce_threshold_pct": enforce_threshold,
|
||||
"editorial_curated_total": _CURATED_GLOBAL,
|
||||
"editorial_sampled_total": _SAMPLED_GLOBAL,
|
||||
"per_theme": per_theme_stats,
|
||||
"per_theme_errors": dict(list(_PREVIEW_PER_THEME_ERRORS.items())[:50]),
|
||||
"curated_synergy_matrix_loaded": curated_synergy_loaded,
|
||||
"curated_synergy_matrix_size": curated_synergy_size,
|
||||
"splash_off_color_total_cards": _SPLASH_OFF_COLOR_TOTAL,
|
||||
"splash_previews_with_penalty": _SPLASH_PREVIEWS_WITH_PENALTY,
|
||||
"splash_penalty_reason_events": _SPLASH_PENALTY_CARD_EVENTS,
|
||||
"redis_get_attempts": _REDIS_GET_ATTEMPTS,
|
||||
"redis_get_hits": _REDIS_GET_HITS,
|
||||
"redis_get_errors": _REDIS_GET_ERRORS,
|
||||
"redis_store_attempts": _REDIS_STORE_ATTEMPTS,
|
||||
"redis_store_errors": _REDIS_STORE_ERRORS,
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"record_build_duration",
|
||||
"record_role_counts",
|
||||
"record_curated_sampled",
|
||||
"record_per_theme",
|
||||
"record_request",
|
||||
"record_per_theme_request",
|
||||
"record_per_theme_error",
|
||||
"record_eviction",
|
||||
"preview_metrics",
|
||||
"configure_external_access",
|
||||
"record_splash_analytics",
|
||||
"record_redis_get",
|
||||
"record_redis_store",
|
||||
]
|
||||
|
||||
def record_per_theme_request(slug: str) -> None:
|
||||
"""Increment request counter for a specific theme (cache hit or miss).
|
||||
|
||||
This was previously in the monolith; extracted to keep per-theme request
|
||||
counts consistent with new metrics module ownership.
|
||||
"""
|
||||
_PREVIEW_PER_THEME_REQUESTS[slug] = _PREVIEW_PER_THEME_REQUESTS.get(slug, 0) + 1
|
||||
|
||||
def record_eviction(meta: Dict[str, Any]) -> None:
|
||||
"""Record a cache eviction event.
|
||||
|
||||
meta expected keys: reason, hit_count, age_ms, build_cost_ms, protection_score, cache_limit,
|
||||
size_before, size_after.
|
||||
"""
|
||||
global _EVICTION_TOTAL, _EVICTION_LAST
|
||||
_EVICTION_TOTAL += 1
|
||||
reason = meta.get("reason", "unknown")
|
||||
_EVICTION_BY_REASON[reason] = _EVICTION_BY_REASON.get(reason, 0) + 1
|
||||
_EVICTION_LAST = meta
|
||||
# Optional structured log
|
||||
try: # pragma: no cover
|
||||
if (os.getenv("WEB_THEME_PREVIEW_LOG") or "").lower() in {"1","true","yes","on"}:
|
||||
import json as _json
|
||||
print(_json.dumps({"event": "theme_preview_cache_evict", **meta}, separators=(",",":"))) # noqa: T201
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def record_splash_analytics(off_color_card_count: int, penalty_reason_events: int) -> None:
|
||||
"""Record splash off-color analytics for a single preview build.
|
||||
|
||||
off_color_card_count: number of sampled cards marked with _splash_off_color flag.
|
||||
penalty_reason_events: count of 'splash_off_color_penalty' reason entries encountered.
|
||||
"""
|
||||
global _SPLASH_OFF_COLOR_TOTAL, _SPLASH_PREVIEWS_WITH_PENALTY, _SPLASH_PENALTY_CARD_EVENTS
|
||||
if off_color_card_count > 0:
|
||||
_SPLASH_PREVIEWS_WITH_PENALTY += 1
|
||||
_SPLASH_OFF_COLOR_TOTAL += off_color_card_count
|
||||
if penalty_reason_events > 0:
|
||||
_SPLASH_PENALTY_CARD_EVENTS += penalty_reason_events
|
||||
167
code/web/services/preview_policy.py
Normal file
167
code/web/services/preview_policy.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
"""Preview policy module (Phase 2 extraction).
|
||||
|
||||
Extracts adaptive TTL band logic so experimentation can occur without
|
||||
touching core cache data structures. Future extensions will add:
|
||||
- Environment-variable overrides for band thresholds & step sizes
|
||||
- Adaptive eviction strategy (hit-ratio + recency hybrid)
|
||||
- Backend abstraction tuning knobs (e.g., Redis TTL harmonization)
|
||||
|
||||
Current exported API is intentionally small/stable:
|
||||
|
||||
compute_ttl_adjustment(hit_ratio: float, current_ttl: int,
|
||||
base: int = DEFAULT_TTL_BASE,
|
||||
ttl_min: int = DEFAULT_TTL_MIN,
|
||||
ttl_max: int = DEFAULT_TTL_MAX) -> int
|
||||
Given the recent hit ratio (0..1) and current TTL, returns the new TTL
|
||||
after applying banded adjustment rules. Never mutates globals; caller
|
||||
decides whether to commit the change.
|
||||
|
||||
Constants kept here mirror the prior inline values from preview_cache.
|
||||
They are NOT yet configurable via env to keep behavior unchanged for
|
||||
existing tests. A follow-up task will add env override + validation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_TTL_BASE",
|
||||
"DEFAULT_TTL_MIN",
|
||||
"DEFAULT_TTL_MAX",
|
||||
"BAND_LOW_CRITICAL",
|
||||
"BAND_LOW_MODERATE",
|
||||
"BAND_HIGH_GROW",
|
||||
"compute_ttl_adjustment",
|
||||
]
|
||||
|
||||
DEFAULT_TTL_BASE = 600
|
||||
DEFAULT_TTL_MIN = 300
|
||||
DEFAULT_TTL_MAX = 900
|
||||
|
||||
# Default hit ratio band thresholds (exclusive upper bounds for each tier)
|
||||
_DEFAULT_BAND_LOW_CRITICAL = 0.25 # Severe miss rate – shrink TTL aggressively
|
||||
_DEFAULT_BAND_LOW_MODERATE = 0.55 # Mild miss bias – converge back toward base
|
||||
_DEFAULT_BAND_HIGH_GROW = 0.75 # Healthy hit rate – modest growth
|
||||
|
||||
# Public band variables (may be overridden via env at import time)
|
||||
BAND_LOW_CRITICAL = _DEFAULT_BAND_LOW_CRITICAL
|
||||
BAND_LOW_MODERATE = _DEFAULT_BAND_LOW_MODERATE
|
||||
BAND_HIGH_GROW = _DEFAULT_BAND_HIGH_GROW
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdjustmentSteps:
|
||||
low_critical: int = -60
|
||||
low_mod_decrease: int = -30
|
||||
low_mod_increase: int = 30
|
||||
high_grow: int = 60
|
||||
high_peak: int = 90 # very high hit ratio
|
||||
|
||||
_STEPS = AdjustmentSteps()
|
||||
|
||||
# --- Environment Override Support (POLICY Env overrides task) --- #
|
||||
_ENV_APPLIED = False
|
||||
|
||||
def _parse_float_env(name: str, default: float) -> float:
|
||||
raw = os.getenv(name)
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
v = float(raw)
|
||||
if not (0.0 <= v <= 1.0):
|
||||
return default
|
||||
return v
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _parse_int_env(name: str, default: int) -> int:
|
||||
raw = os.getenv(name)
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _apply_env_overrides() -> None:
|
||||
"""Idempotently apply environment overrides for bands & step sizes.
|
||||
|
||||
Env vars:
|
||||
THEME_PREVIEW_TTL_BASE / _MIN / _MAX (ints)
|
||||
THEME_PREVIEW_TTL_BANDS (comma floats: low_critical,low_moderate,high_grow)
|
||||
THEME_PREVIEW_TTL_STEPS (comma ints: low_critical,low_mod_dec,low_mod_inc,high_grow,high_peak)
|
||||
Invalid / partial specs fall back to defaults. Bands are validated to be
|
||||
strictly increasing within (0,1). If validation fails, defaults retained.
|
||||
"""
|
||||
global DEFAULT_TTL_BASE, DEFAULT_TTL_MIN, DEFAULT_TTL_MAX
|
||||
global BAND_LOW_CRITICAL, BAND_LOW_MODERATE, BAND_HIGH_GROW, _STEPS, _ENV_APPLIED
|
||||
if _ENV_APPLIED:
|
||||
return
|
||||
DEFAULT_TTL_BASE = _parse_int_env("THEME_PREVIEW_TTL_BASE", DEFAULT_TTL_BASE)
|
||||
DEFAULT_TTL_MIN = _parse_int_env("THEME_PREVIEW_TTL_MIN", DEFAULT_TTL_MIN)
|
||||
DEFAULT_TTL_MAX = _parse_int_env("THEME_PREVIEW_TTL_MAX", DEFAULT_TTL_MAX)
|
||||
# Ensure ordering min <= base <= max
|
||||
if DEFAULT_TTL_MIN > DEFAULT_TTL_BASE:
|
||||
DEFAULT_TTL_MIN = min(DEFAULT_TTL_MIN, DEFAULT_TTL_BASE)
|
||||
if DEFAULT_TTL_BASE > DEFAULT_TTL_MAX:
|
||||
DEFAULT_TTL_MAX = max(DEFAULT_TTL_BASE, DEFAULT_TTL_MAX)
|
||||
bands_raw = os.getenv("THEME_PREVIEW_TTL_BANDS")
|
||||
if bands_raw:
|
||||
parts = [p.strip() for p in bands_raw.split(',') if p.strip()]
|
||||
vals: list[float] = []
|
||||
for p in parts[:3]:
|
||||
try:
|
||||
vals.append(float(p))
|
||||
except Exception:
|
||||
pass
|
||||
if len(vals) == 3:
|
||||
a, b, c = vals
|
||||
if 0 < a < b < c < 1:
|
||||
BAND_LOW_CRITICAL, BAND_LOW_MODERATE, BAND_HIGH_GROW = a, b, c
|
||||
steps_raw = os.getenv("THEME_PREVIEW_TTL_STEPS")
|
||||
if steps_raw:
|
||||
parts = [p.strip() for p in steps_raw.split(',') if p.strip()]
|
||||
ints: list[int] = []
|
||||
for p in parts[:5]:
|
||||
try:
|
||||
ints.append(int(p))
|
||||
except Exception:
|
||||
pass
|
||||
if len(ints) == 5:
|
||||
_STEPS = AdjustmentSteps(
|
||||
low_critical=ints[0],
|
||||
low_mod_decrease=ints[1],
|
||||
low_mod_increase=ints[2],
|
||||
high_grow=ints[3],
|
||||
high_peak=ints[4],
|
||||
)
|
||||
_ENV_APPLIED = True
|
||||
|
||||
# Apply overrides at import time (safe & idempotent)
|
||||
_apply_env_overrides()
|
||||
|
||||
def compute_ttl_adjustment(
|
||||
hit_ratio: float,
|
||||
current_ttl: int,
|
||||
base: int = DEFAULT_TTL_BASE,
|
||||
ttl_min: int = DEFAULT_TTL_MIN,
|
||||
ttl_max: int = DEFAULT_TTL_MAX,
|
||||
) -> int:
|
||||
"""Return a new TTL based on hit ratio & current TTL.
|
||||
|
||||
Logic mirrors the original inline implementation; extracted for clarity.
|
||||
"""
|
||||
new_ttl = current_ttl
|
||||
if hit_ratio < BAND_LOW_CRITICAL:
|
||||
new_ttl = max(ttl_min, current_ttl + _STEPS.low_critical)
|
||||
elif hit_ratio < BAND_LOW_MODERATE:
|
||||
if current_ttl > base:
|
||||
new_ttl = max(base, current_ttl + _STEPS.low_mod_decrease)
|
||||
elif current_ttl < base:
|
||||
new_ttl = min(base, current_ttl + _STEPS.low_mod_increase)
|
||||
# else already at base – no change
|
||||
elif hit_ratio < BAND_HIGH_GROW:
|
||||
new_ttl = min(ttl_max, current_ttl + _STEPS.high_grow)
|
||||
else:
|
||||
new_ttl = min(ttl_max, current_ttl + _STEPS.high_peak)
|
||||
return new_ttl
|
||||
259
code/web/services/sampling.py
Normal file
259
code/web/services/sampling.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
"""Sampling utilities extracted from theme_preview (Core Refactor Phase A - initial extraction).
|
||||
|
||||
This module contains card index construction and the deterministic sampling
|
||||
pipeline used to build preview role buckets. Logic moved with minimal changes
|
||||
to preserve behavior; future refactor steps will further decompose (e.g.,
|
||||
separating card index & rarity calibration, introducing typed models).
|
||||
|
||||
Public (stable) surface for Phase A:
|
||||
sample_real_cards_for_theme(theme: str, limit: int, colors_filter: str | None,
|
||||
*, synergies: list[str], commander: str | None) -> list[dict]
|
||||
|
||||
Internal helpers intentionally start with an underscore to discourage external
|
||||
use; they may change in subsequent refactor steps.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any, Dict, List, Optional, TypedDict
|
||||
|
||||
from .card_index import maybe_build_index, get_tag_pool, lookup_commander
|
||||
from .sampling_config import (
|
||||
COMMANDER_COLOR_FILTER_STRICT,
|
||||
COMMANDER_OVERLAP_BONUS,
|
||||
COMMANDER_THEME_MATCH_BONUS,
|
||||
SPLASH_OFF_COLOR_PENALTY,
|
||||
SPLASH_ADAPTIVE_ENABLED,
|
||||
parse_splash_adaptive_scale,
|
||||
ROLE_BASE_WEIGHTS,
|
||||
ROLE_SATURATION_PENALTY,
|
||||
rarity_weight_base,
|
||||
parse_rarity_diversity_targets,
|
||||
RARITY_DIVERSITY_OVER_PENALTY,
|
||||
)
|
||||
|
||||
|
||||
_CARD_INDEX_DEPRECATED: Dict[str, List[Dict[str, Any]]] = {} # kept for back-compat in tests; will be removed
|
||||
|
||||
|
||||
class SampledCard(TypedDict, total=False):
|
||||
"""Typed shape for a sampled card entry emitted to preview layer.
|
||||
|
||||
total=False because curated examples / synthetic placeholders may lack
|
||||
full DB-enriched fields (mana_cost, rarity, color_identity_list, etc.).
|
||||
"""
|
||||
name: str
|
||||
colors: List[str]
|
||||
roles: List[str]
|
||||
tags: List[str]
|
||||
score: float
|
||||
reasons: List[str]
|
||||
mana_cost: str
|
||||
rarity: str
|
||||
color_identity_list: List[str]
|
||||
pip_colors: List[str]
|
||||
|
||||
|
||||
def _classify_role(theme: str, synergies: List[str], tags: List[str]) -> str:
|
||||
tag_set = set(tags)
|
||||
synergy_overlap = tag_set.intersection(synergies)
|
||||
if theme in tag_set:
|
||||
return "payoff"
|
||||
if len(synergy_overlap) >= 2:
|
||||
return "enabler"
|
||||
if len(synergy_overlap) == 1:
|
||||
return "support"
|
||||
return "wildcard"
|
||||
|
||||
|
||||
def _seed_from(theme: str, commander: Optional[str]) -> int:
|
||||
base = f"{theme.lower()}|{(commander or '').lower()}".encode("utf-8")
|
||||
h = 0
|
||||
for b in base:
|
||||
h = (h * 131 + b) & 0xFFFFFFFF
|
||||
return h or 1
|
||||
|
||||
|
||||
def _deterministic_shuffle(items: List[Any], seed: int) -> None:
|
||||
rnd = random.Random(seed)
|
||||
rnd.shuffle(items)
|
||||
|
||||
|
||||
def _score_card(theme: str, synergies: List[str], role: str, tags: List[str]) -> float:
|
||||
tag_set = set(tags)
|
||||
synergy_overlap = len(tag_set.intersection(synergies))
|
||||
score = 0.0
|
||||
if theme in tag_set:
|
||||
score += 3.0
|
||||
score += synergy_overlap * 1.2
|
||||
score += ROLE_BASE_WEIGHTS.get(role, 0.5)
|
||||
return score
|
||||
|
||||
|
||||
def _commander_overlap_scale(commander_tags: set[str], card_tags: List[str], synergy_set: set[str]) -> float:
|
||||
if not commander_tags or not synergy_set:
|
||||
return 0.0
|
||||
overlap_synergy = len(commander_tags.intersection(synergy_set).intersection(card_tags))
|
||||
if overlap_synergy <= 0:
|
||||
return 0.0
|
||||
return COMMANDER_OVERLAP_BONUS * (1 - (0.5 ** overlap_synergy))
|
||||
|
||||
|
||||
def _lookup_commander(commander: Optional[str]) -> Optional[Dict[str, Any]]: # thin wrapper for legacy name
|
||||
return lookup_commander(commander)
|
||||
|
||||
|
||||
def sample_real_cards_for_theme(theme: str, limit: int, colors_filter: Optional[str], *, synergies: List[str], commander: Optional[str]) -> List[SampledCard]:
|
||||
"""Return scored, role-classified real cards for a theme.
|
||||
|
||||
Mirrors prior `_sample_real_cards_for_theme` behavior for parity.
|
||||
"""
|
||||
maybe_build_index()
|
||||
pool = get_tag_pool(theme)
|
||||
if not pool:
|
||||
return []
|
||||
commander_card = _lookup_commander(commander)
|
||||
commander_colors: set[str] = set(commander_card.get("color_identity", "")) if commander_card else set()
|
||||
commander_tags: set[str] = set(commander_card.get("tags", [])) if commander_card else set()
|
||||
if colors_filter:
|
||||
allowed = {c.strip().upper() for c in colors_filter.split(',') if c.strip()}
|
||||
if allowed:
|
||||
pool = [c for c in pool if set(c.get("color_identity", "")).issubset(allowed) or not c.get("color_identity")]
|
||||
if commander_card and COMMANDER_COLOR_FILTER_STRICT and commander_colors:
|
||||
allow_splash = len(commander_colors) >= 4
|
||||
new_pool: List[Dict[str, Any]] = []
|
||||
for c in pool:
|
||||
ci = set(c.get("color_identity", ""))
|
||||
if not ci or ci.issubset(commander_colors):
|
||||
new_pool.append(c)
|
||||
continue
|
||||
if allow_splash:
|
||||
off = ci - commander_colors
|
||||
if len(off) == 1:
|
||||
c["_splash_off_color"] = True # type: ignore
|
||||
new_pool.append(c)
|
||||
continue
|
||||
pool = new_pool
|
||||
seen_names: set[str] = set()
|
||||
payoff: List[SampledCard] = []
|
||||
enabler: List[SampledCard] = []
|
||||
support: List[SampledCard] = []
|
||||
wildcard: List[SampledCard] = []
|
||||
rarity_counts: Dict[str, int] = {}
|
||||
rarity_diversity = parse_rarity_diversity_targets()
|
||||
synergy_set = set(synergies)
|
||||
rarity_weight_cfg = rarity_weight_base()
|
||||
splash_scale = parse_splash_adaptive_scale() if SPLASH_ADAPTIVE_ENABLED else None
|
||||
commander_color_count = len(commander_colors) if commander_colors else 0
|
||||
for raw in pool:
|
||||
nm = raw.get("name")
|
||||
if not nm or nm in seen_names:
|
||||
continue
|
||||
seen_names.add(nm)
|
||||
tags = raw.get("tags", [])
|
||||
role = _classify_role(theme, synergies, tags)
|
||||
score = _score_card(theme, synergies, role, tags)
|
||||
reasons = [f"role:{role}", f"synergy_overlap:{len(set(tags).intersection(synergies))}"]
|
||||
if commander_card:
|
||||
if theme in tags:
|
||||
score += COMMANDER_THEME_MATCH_BONUS
|
||||
reasons.append("commander_theme_match")
|
||||
scaled = _commander_overlap_scale(commander_tags, tags, synergy_set)
|
||||
if scaled:
|
||||
score += scaled
|
||||
reasons.append(f"commander_synergy_overlap:{len(commander_tags.intersection(synergy_set).intersection(tags))}:{round(scaled,2)}")
|
||||
reasons.append("commander_bias")
|
||||
rarity = raw.get("rarity") or ""
|
||||
if rarity:
|
||||
base_rarity_weight = rarity_weight_cfg.get(rarity, 0.25)
|
||||
count_so_far = rarity_counts.get(rarity, 0)
|
||||
increment_weight = base_rarity_weight / (1 + 0.4 * count_so_far)
|
||||
score += increment_weight
|
||||
rarity_counts[rarity] = count_so_far + 1
|
||||
reasons.append(f"rarity_weight_calibrated:{rarity}:{round(increment_weight,2)}")
|
||||
if rarity_diversity and rarity in rarity_diversity:
|
||||
lo, hi = rarity_diversity[rarity]
|
||||
# Only enforce upper bound (overflow penalty)
|
||||
if rarity_counts[rarity] > hi:
|
||||
score += RARITY_DIVERSITY_OVER_PENALTY
|
||||
reasons.append(f"rarity_diversity_overflow:{rarity}:{hi}:{RARITY_DIVERSITY_OVER_PENALTY}")
|
||||
if raw.get("_splash_off_color"):
|
||||
penalty = SPLASH_OFF_COLOR_PENALTY
|
||||
if splash_scale and commander_color_count:
|
||||
scale = splash_scale.get(commander_color_count, 1.0)
|
||||
adaptive_penalty = round(penalty * scale, 4)
|
||||
score += adaptive_penalty
|
||||
reasons.append(f"splash_off_color_penalty_adaptive:{commander_color_count}:{adaptive_penalty}")
|
||||
else:
|
||||
score += penalty # negative value
|
||||
reasons.append(f"splash_off_color_penalty:{penalty}")
|
||||
item: SampledCard = {
|
||||
"name": nm,
|
||||
"colors": list(raw.get("color_identity", "")),
|
||||
"roles": [role],
|
||||
"tags": tags,
|
||||
"score": score,
|
||||
"reasons": reasons,
|
||||
"mana_cost": raw.get("mana_cost"),
|
||||
"rarity": rarity,
|
||||
"color_identity_list": raw.get("color_identity_list", []),
|
||||
"pip_colors": raw.get("pip_colors", []),
|
||||
}
|
||||
if role == "payoff":
|
||||
payoff.append(item)
|
||||
elif role == "enabler":
|
||||
enabler.append(item)
|
||||
elif role == "support":
|
||||
support.append(item)
|
||||
else:
|
||||
wildcard.append(item)
|
||||
seed = _seed_from(theme, commander)
|
||||
for bucket in (payoff, enabler, support, wildcard):
|
||||
_deterministic_shuffle(bucket, seed)
|
||||
bucket.sort(key=lambda x: (-x["score"], x["name"]))
|
||||
target_payoff = max(1, int(round(limit * 0.4)))
|
||||
target_enabler_support = max(1, int(round(limit * 0.4)))
|
||||
target_wild = max(0, limit - target_payoff - target_enabler_support)
|
||||
|
||||
def take(n: int, source: List[SampledCard]):
|
||||
for i in range(min(n, len(source))):
|
||||
yield source[i]
|
||||
|
||||
chosen: List[SampledCard] = []
|
||||
chosen.extend(take(target_payoff, payoff))
|
||||
es_combined = enabler + support
|
||||
chosen.extend(take(target_enabler_support, es_combined))
|
||||
chosen.extend(take(target_wild, wildcard))
|
||||
|
||||
if len(chosen) < limit:
|
||||
def fill_from(src: List[SampledCard]):
|
||||
nonlocal chosen
|
||||
for it in src:
|
||||
if len(chosen) >= limit:
|
||||
break
|
||||
if it not in chosen:
|
||||
chosen.append(it)
|
||||
for bucket in (payoff, enabler, support, wildcard):
|
||||
fill_from(bucket)
|
||||
|
||||
role_soft_caps = {
|
||||
"payoff": int(round(limit * 0.5)),
|
||||
"enabler": int(round(limit * 0.35)),
|
||||
"support": int(round(limit * 0.35)),
|
||||
"wildcard": int(round(limit * 0.25)),
|
||||
}
|
||||
role_seen: Dict[str, int] = {k: 0 for k in role_soft_caps}
|
||||
for it in chosen:
|
||||
r = (it.get("roles") or [None])[0]
|
||||
if not r or r not in role_soft_caps:
|
||||
continue
|
||||
role_seen[r] += 1
|
||||
if role_seen[r] > max(1, role_soft_caps[r]):
|
||||
it["score"] = it.get("score", 0) + ROLE_SATURATION_PENALTY # negative value
|
||||
(it.setdefault("reasons", [])).append(f"role_saturation_penalty:{ROLE_SATURATION_PENALTY}")
|
||||
if len(chosen) > limit:
|
||||
chosen = chosen[:limit]
|
||||
return chosen
|
||||
|
||||
# Expose overlap scale for unit tests
|
||||
commander_overlap_scale = _commander_overlap_scale
|
||||
123
code/web/services/sampling_config.py
Normal file
123
code/web/services/sampling_config.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""Scoring & sampling configuration constants (Phase 2 extraction).
|
||||
|
||||
Centralizes knobs used by the sampling pipeline so future tuning (or
|
||||
experimentation via environment variables) can occur without editing the
|
||||
core algorithm code.
|
||||
|
||||
Public constants (import into sampling.py and tests):
|
||||
COMMANDER_COLOR_FILTER_STRICT
|
||||
COMMANDER_OVERLAP_BONUS
|
||||
COMMANDER_THEME_MATCH_BONUS
|
||||
SPLASH_OFF_COLOR_PENALTY
|
||||
ROLE_BASE_WEIGHTS
|
||||
ROLE_SATURATION_PENALTY
|
||||
|
||||
Helper functions:
|
||||
rarity_weight_base() -> dict[str, float]
|
||||
Returns per-rarity base weights (reads env each call to preserve
|
||||
existing test expectations that patch env before invoking sampling).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Dict, Tuple, Optional
|
||||
|
||||
# Commander related bonuses (identical defaults to previous inline values)
|
||||
COMMANDER_COLOR_FILTER_STRICT = True
|
||||
COMMANDER_OVERLAP_BONUS = 1.8
|
||||
COMMANDER_THEME_MATCH_BONUS = 0.9
|
||||
|
||||
# Penalties / bonuses
|
||||
SPLASH_OFF_COLOR_PENALTY = -0.3
|
||||
# Adaptive splash penalty feature flag & scaling factors.
|
||||
# When SPLASH_ADAPTIVE=1 the effective penalty becomes:
|
||||
# base_penalty * splash_adaptive_scale(color_count)
|
||||
# Where color_count is the number of distinct commander colors (1-5).
|
||||
# Default scale keeps existing behavior at 1-3 colors, softens at 4, much lighter at 5.
|
||||
SPLASH_ADAPTIVE_ENABLED = os.getenv("SPLASH_ADAPTIVE", "0") == "1"
|
||||
_DEFAULT_SPLASH_SCALE = "1:1.0,2:1.0,3:1.0,4:0.6,5:0.35"
|
||||
def parse_splash_adaptive_scale() -> Dict[int, float]: # dynamic to allow test env changes
|
||||
spec = os.getenv("SPLASH_ADAPTIVE_SCALE", _DEFAULT_SPLASH_SCALE)
|
||||
mapping: Dict[int, float] = {}
|
||||
for part in spec.split(','):
|
||||
part = part.strip()
|
||||
if not part or ':' not in part:
|
||||
continue
|
||||
k_s, v_s = part.split(':', 1)
|
||||
try:
|
||||
k = int(k_s)
|
||||
v = float(v_s)
|
||||
if 1 <= k <= 5 and v > 0:
|
||||
mapping[k] = v
|
||||
except Exception:
|
||||
continue
|
||||
# Ensure all 1-5 present; fallback to 1.0 if unspecified
|
||||
for i in range(1, 6):
|
||||
mapping.setdefault(i, 1.0)
|
||||
return mapping
|
||||
ROLE_SATURATION_PENALTY = -0.4
|
||||
|
||||
# Base role weights applied inside score calculation
|
||||
ROLE_BASE_WEIGHTS: Dict[str, float] = {
|
||||
"payoff": 2.5,
|
||||
"enabler": 2.0,
|
||||
"support": 1.5,
|
||||
"wildcard": 0.9,
|
||||
}
|
||||
|
||||
# Rarity base weights (diminishing duplicate influence applied in sampling pipeline)
|
||||
# Read from env at call time to allow tests to modify.
|
||||
|
||||
def rarity_weight_base() -> Dict[str, float]: # dynamic to allow env override per test
|
||||
return {
|
||||
"mythic": float(os.getenv("RARITY_W_MYTHIC", "1.2")),
|
||||
"rare": float(os.getenv("RARITY_W_RARE", "0.9")),
|
||||
"uncommon": float(os.getenv("RARITY_W_UNCOMMON", "0.65")),
|
||||
"common": float(os.getenv("RARITY_W_COMMON", "0.4")),
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"COMMANDER_COLOR_FILTER_STRICT",
|
||||
"COMMANDER_OVERLAP_BONUS",
|
||||
"COMMANDER_THEME_MATCH_BONUS",
|
||||
"SPLASH_OFF_COLOR_PENALTY",
|
||||
"SPLASH_ADAPTIVE_ENABLED",
|
||||
"parse_splash_adaptive_scale",
|
||||
"ROLE_BASE_WEIGHTS",
|
||||
"ROLE_SATURATION_PENALTY",
|
||||
"rarity_weight_base",
|
||||
"parse_rarity_diversity_targets",
|
||||
"RARITY_DIVERSITY_OVER_PENALTY",
|
||||
]
|
||||
|
||||
|
||||
# Extended rarity diversity (optional) ---------------------------------------
|
||||
# Env var RARITY_DIVERSITY_TARGETS pattern e.g. "mythic:0-1,rare:0-2,uncommon:0-4,common:0-6"
|
||||
# Parsed into mapping rarity -> (min,max). Only max is enforced currently (penalty applied
|
||||
# when overflow occurs); min reserved for potential future boosting logic.
|
||||
|
||||
RARITY_DIVERSITY_OVER_PENALTY = float(os.getenv("RARITY_DIVERSITY_OVER_PENALTY", "-0.5"))
|
||||
|
||||
def parse_rarity_diversity_targets() -> Optional[Dict[str, Tuple[int, int]]]:
|
||||
spec = os.getenv("RARITY_DIVERSITY_TARGETS")
|
||||
if not spec:
|
||||
return None
|
||||
targets: Dict[str, Tuple[int, int]] = {}
|
||||
for part in spec.split(','):
|
||||
part = part.strip()
|
||||
if not part or ':' not in part:
|
||||
continue
|
||||
name, rng = part.split(':', 1)
|
||||
name = name.strip().lower()
|
||||
if '-' not in rng:
|
||||
continue
|
||||
lo_s, hi_s = rng.split('-', 1)
|
||||
try:
|
||||
lo = int(lo_s)
|
||||
hi = int(hi_s)
|
||||
if lo < 0 or hi < lo:
|
||||
continue
|
||||
targets[name] = (lo, hi)
|
||||
except Exception:
|
||||
continue
|
||||
return targets or None
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,85 @@
|
|||
// Minimal service worker (stub). Controlled by ENABLE_PWA.
|
||||
// Service Worker for MTG Deckbuilder
|
||||
// Versioned via ?v=<catalog_hash> appended at registration time.
|
||||
// Strategies:
|
||||
// 1. Precache core shell assets (app shell + styles + manifest).
|
||||
// 2. Runtime cache (stale-while-revalidate) for theme list & preview fragments.
|
||||
// 3. Version bump (catalog hash change) triggers old cache purge.
|
||||
|
||||
const VERSION = (new URL(self.location.href)).searchParams.get('v') || 'dev';
|
||||
const PRECACHE = `precache-v${VERSION}`;
|
||||
const RUNTIME = `runtime-v${VERSION}`;
|
||||
const CORE_ASSETS = [
|
||||
'/',
|
||||
'/themes/',
|
||||
'/static/styles.css',
|
||||
'/static/app.js',
|
||||
'/static/manifest.webmanifest',
|
||||
'/static/favicon.png'
|
||||
];
|
||||
|
||||
// Utility: limit entries in a cache (simple LRU-esque trim by deletion order)
|
||||
async function trimCache(cacheName, maxEntries){
|
||||
const cache = await caches.open(cacheName);
|
||||
const keys = await cache.keys();
|
||||
if(keys.length <= maxEntries) return;
|
||||
const remove = keys.slice(0, keys.length - maxEntries);
|
||||
await Promise.all(remove.map(k => cache.delete(k)));
|
||||
}
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
self.skipWaiting();
|
||||
event.waitUntil(
|
||||
caches.open(PRECACHE).then(cache => cache.addAll(CORE_ASSETS)).then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(clients.claim());
|
||||
event.waitUntil((async () => {
|
||||
// Remove old versioned caches
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.filter(k => (k.startsWith('precache-v') || k.startsWith('runtime-v')) && !k.endsWith(VERSION)).map(k => caches.delete(k)));
|
||||
await clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
function isPreviewRequest(url){
|
||||
return /\/themes\/preview\//.test(url.pathname);
|
||||
}
|
||||
function isThemeList(url){
|
||||
return url.pathname === '/themes/' || url.pathname.startsWith('/themes?');
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
// Pass-through; caching strategy can be added later.
|
||||
const req = event.request;
|
||||
const url = new URL(req.url);
|
||||
if(req.method !== 'GET') return; // Non-GET pass-through
|
||||
|
||||
// Core assets: cache-first
|
||||
if(CORE_ASSETS.includes(url.pathname)){
|
||||
event.respondWith(
|
||||
caches.open(PRECACHE).then(cache => cache.match(req).then(found => {
|
||||
return found || fetch(req).then(resp => { cache.put(req, resp.clone()); return resp; });
|
||||
}))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Theme list / preview fragments: stale-while-revalidate
|
||||
if(isPreviewRequest(url) || isThemeList(url)){
|
||||
event.respondWith((async () => {
|
||||
const cache = await caches.open(RUNTIME);
|
||||
const cached = await cache.match(req);
|
||||
const fetchPromise = fetch(req).then(resp => {
|
||||
if(resp && resp.status === 200){ cache.put(req, resp.clone()); trimCache(RUNTIME, 120).catch(()=>{}); }
|
||||
return resp;
|
||||
}).catch(() => cached);
|
||||
return cached || fetchPromise;
|
||||
})());
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if(event.data && event.data.type === 'SKIP_WAITING'){
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -328,7 +328,15 @@
|
|||
}
|
||||
var cardPop = ensureCard();
|
||||
var PREVIEW_VERSIONS = ['normal','large'];
|
||||
function normalizeCardName(raw){
|
||||
if(!raw) return raw;
|
||||
// Strip ' - Synergy (...' annotation if present
|
||||
var m = /(.*?)(\s*-\s*Synergy\s*\(.*\))$/i.exec(raw);
|
||||
if(m){ return m[1].trim(); }
|
||||
return raw;
|
||||
}
|
||||
function buildCardUrl(name, version, nocache, face){
|
||||
name = normalizeCardName(name);
|
||||
var q = encodeURIComponent(name||'');
|
||||
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
|
||||
if (face === 'back') url += '&face=back';
|
||||
|
|
@ -337,6 +345,7 @@
|
|||
}
|
||||
// Generic Scryfall image URL builder
|
||||
function buildScryfallImageUrl(name, version, nocache){
|
||||
name = normalizeCardName(name);
|
||||
var q = encodeURIComponent(name||'');
|
||||
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
|
||||
if (nocache) url += '&t=' + Date.now();
|
||||
|
|
@ -519,11 +528,11 @@
|
|||
var lastFlip = 0;
|
||||
function hasTwoFaces(card){
|
||||
if(!card) return false;
|
||||
var name = (card.getAttribute('data-card-name')||'') + ' ' + (card.getAttribute('data-original-name')||'');
|
||||
var name = normalizeCardName((card.getAttribute('data-card-name')||'')) + ' ' + normalizeCardName((card.getAttribute('data-original-name')||''));
|
||||
return name.indexOf('//') > -1;
|
||||
}
|
||||
function keyFor(card){
|
||||
var nm = (card.getAttribute('data-card-name')|| card.getAttribute('data-original-name')||'').toLowerCase();
|
||||
var nm = normalizeCardName(card.getAttribute('data-card-name')|| card.getAttribute('data-original-name')||'').toLowerCase();
|
||||
return LS_PREFIX + nm;
|
||||
}
|
||||
function applyStoredFace(card){
|
||||
|
|
@ -543,7 +552,7 @@
|
|||
live.id = 'dfc-live'; live.className='sr-only'; live.setAttribute('aria-live','polite');
|
||||
document.body.appendChild(live);
|
||||
}
|
||||
var nm = (card.getAttribute('data-card-name')||'').split('//')[0].trim();
|
||||
var nm = normalizeCardName(card.getAttribute('data-card-name')||'').split('//')[0].trim();
|
||||
live.textContent = 'Showing ' + (face==='front'?'front face':'back face') + ' of ' + nm;
|
||||
}
|
||||
function updateButton(btn, face){
|
||||
|
|
@ -714,8 +723,24 @@
|
|||
(function(){
|
||||
try{
|
||||
if ('serviceWorker' in navigator){
|
||||
navigator.serviceWorker.register('/static/sw.js').then(function(reg){
|
||||
window.__pwaStatus = { registered: true, scope: reg.scope };
|
||||
var ver = '{{ catalog_hash|default("dev") }}';
|
||||
var url = '/static/sw.js?v=' + encodeURIComponent(ver);
|
||||
navigator.serviceWorker.register(url).then(function(reg){
|
||||
window.__pwaStatus = { registered: true, scope: reg.scope, version: ver };
|
||||
// Listen for updates (new worker installing)
|
||||
if(reg.waiting){ reg.waiting.postMessage({ type: 'SKIP_WAITING' }); }
|
||||
reg.addEventListener('updatefound', function(){
|
||||
try {
|
||||
var nw = reg.installing; if(!nw) return;
|
||||
nw.addEventListener('statechange', function(){
|
||||
if(nw.state === 'installed' && navigator.serviceWorker.controller){
|
||||
// New version available; reload silently for freshness
|
||||
try { sessionStorage.setItem('mtg:swUpdated','1'); }catch(_){ }
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}catch(_){ }
|
||||
});
|
||||
}).catch(function(){ window.__pwaStatus = { registered: false }; });
|
||||
}
|
||||
}catch(_){ }
|
||||
|
|
|
|||
|
|
@ -74,8 +74,10 @@
|
|||
{% if inspect and inspect.ok %}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview card-sm" data-card-name="{{ selected }}">
|
||||
<a href="https://scryfall.com/search?q={{ selected|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ selected|urlencode }}&format=image&version=normal" alt="{{ selected }} card image" data-card-name="{{ selected }}" />
|
||||
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
|
||||
{% set sel_base = (selected.split(' - Synergy (')[0] if ' - Synergy (' in selected else selected) %}
|
||||
<a href="https://scryfall.com/search?q={{ sel_base|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ sel_base|urlencode }}&format=image&version=normal" alt="{{ selected }} card image" data-card-name="{{ sel_base }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow">
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview" data-card-name="{{ commander.name }}">
|
||||
<a href="https://scryfall.com/search?q={{ commander.name|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander.name|urlencode }}&format=image&version=normal" alt="{{ commander.name }} card image" data-card-name="{{ commander.name }}" />
|
||||
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
|
||||
{% set commander_base = (commander.name.split(' - Synergy (')[0] if ' - Synergy (' in commander.name else commander.name) %}
|
||||
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander.name }} card image" data-card-name="{{ commander_base }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
|
||||
{# Ensure synergy annotation suffix is stripped for Scryfall query and image fuzzy param #}
|
||||
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
|
||||
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
|
||||
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
|
||||
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
|
||||
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview">
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=large 672w"
|
||||
{# Strip synergy annotation for Scryfall search #}
|
||||
<a href="https://scryfall.com/search?q={{ (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander)|urlencode }}" target="_blank" rel="noopener">
|
||||
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=large 672w"
|
||||
sizes="(max-width: 900px) 100vw, 320px" />
|
||||
</a>
|
||||
{% if status and status.startswith('Build complete') %}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@
|
|||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview">
|
||||
{% if commander %}
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" width="320" data-card-name="{{ commander }}" />
|
||||
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
|
||||
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
|
||||
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" width="320" data-card-name="{{ commander_base }}" />
|
||||
</a>
|
||||
{% endif %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@
|
|||
<div class="two-col two-col-left-rail" style="margin-top:.75rem;">
|
||||
<aside class="card-preview">
|
||||
{% if commander %}
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" width="320" />
|
||||
{# Strip synergy annotation for Scryfall search and image fuzzy param #}
|
||||
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
|
||||
<a href="https://scryfall.com/search?q={{ commander_base|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander_base|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander_base }}" width="320" />
|
||||
</a>
|
||||
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}">{{ commander }}</span></div>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -45,9 +45,10 @@
|
|||
<div class="example-card-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:.85rem;">
|
||||
{% if theme.example_cards %}
|
||||
{% for c in theme.example_cards %}
|
||||
<div class="ex-card card-sample" style="text-align:center;" data-card-name="{{ c }}" data-role="example_card" data-tags="{{ theme.synergies|join(', ') }}">
|
||||
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ c }} image" style="width:100%; height:auto; border:1px solid var(--border); border-radius:10px;" src="https://api.scryfall.com/cards/named?fuzzy={{ c|urlencode }}&format=image&version=small" />
|
||||
<div style="font-size:11px; margin-top:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:600;" class="card-ref" data-card-name="{{ c }}" data-tags="{{ theme.synergies|join(', ') }}">{{ c }}</div>
|
||||
{% set base_c = (c.split(' - Synergy (')[0] if ' - Synergy (' in c else c) %}
|
||||
<div class="ex-card card-sample" style="text-align:center;" data-card-name="{{ base_c }}" data-role="example_card" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">
|
||||
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ c }} image" style="width:100%; height:auto; border:1px solid var(--border); border-radius:10px;" src="https://api.scryfall.com/cards/named?fuzzy={{ base_c|urlencode }}&format=image&version=small" />
|
||||
<div style="font-size:11px; margin-top:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:600;" class="card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
|
|
@ -58,9 +59,10 @@
|
|||
<div class="example-commander-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:.85rem;">
|
||||
{% if theme.example_commanders %}
|
||||
{% for c in theme.example_commanders %}
|
||||
<div class="ex-commander commander-cell" style="text-align:center;" data-card-name="{{ c }}" data-role="commander_example" data-tags="{{ theme.synergies|join(', ') }}">
|
||||
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ c }} image" style="width:100%; height:auto; border:1px solid var(--border); border-radius:10px;" src="https://api.scryfall.com/cards/named?fuzzy={{ c|urlencode }}&format=image&version=small" />
|
||||
<div style="font-size:11px; margin-top:4px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" class="card-ref" data-card-name="{{ c }}" data-tags="{{ theme.synergies|join(', ') }}">{{ c }}</div>
|
||||
{% set base_c = (c.split(' - Synergy (')[0] if ' - Synergy (' in c else c) %}
|
||||
<div class="ex-commander commander-cell" style="text-align:center;" data-card-name="{{ base_c }}" data-role="commander_example" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">
|
||||
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ c }} image" style="width:100%; height:auto; border:1px solid var(--border); border-radius:10px;" src="https://api.scryfall.com/cards/named?fuzzy={{ base_c|urlencode }}&format=image&version=small" />
|
||||
<div style="font-size:11px; margin-top:4px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" class="card-ref" data-card-name="{{ base_c }}" data-tags="{{ theme.synergies|join(', ') }}" data-original-name="{{ c }}">{{ c }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
|
|
@ -81,4 +83,25 @@
|
|||
(function(){
|
||||
try { var h=document.getElementById('theme-detail-heading-{{ theme.id }}'); if(h){ h.focus({preventScroll:false}); } } catch(_e){}
|
||||
})();
|
||||
// Post-render normalization: ensure any annotated ' - Synergy (...)' names use base name for Scryfall URLs
|
||||
(function(){
|
||||
try {
|
||||
document.querySelectorAll('.example-card-grid img.card-thumb, .example-commander-grid img.card-thumb').forEach(function(img){
|
||||
var orig = img.getAttribute('data-original-name') || img.getAttribute('data-card-name') || '';
|
||||
var m = /(.*?)(\s*-\s*Synergy\s*\(.*\))$/i.exec(orig);
|
||||
if(m){
|
||||
var base = m[1].trim();
|
||||
if(base){
|
||||
img.setAttribute('data-card-name', base);
|
||||
var current = img.getAttribute('src')||'';
|
||||
// Replace fuzzy param only if it still contains the annotated portion
|
||||
var before = decodeURIComponent((current.split('fuzzy=')[1]||'').split('&')[0] || '');
|
||||
if(before && before !== base){
|
||||
img.src = 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(base) + '&format=image&version=small';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
<div class="preview-controls" style="display:flex; gap:1rem; align-items:center; margin:.5rem 0 .75rem; font-size:11px;">
|
||||
<label style="display:inline-flex; gap:4px; align-items:center;"><input type="checkbox" id="curated-only-toggle"/> Curated Only</label>
|
||||
<label style="display:inline-flex; gap:4px; align-items:center;"><input type="checkbox" id="reasons-toggle" checked/> Reasons <span style="opacity:.55; font-size:10px; cursor:help;" title="Toggle why the payoff is included (i.e. overlapping themes or other reasoning)">?</span></label>
|
||||
<label style="display:inline-flex; gap:4px; align-items:center;"><input type="checkbox" id="show-duplicates-toggle"/> Show Collapsed Duplicates</label>
|
||||
<span id="preview-status" aria-live="polite" style="opacity:.65;"></span>
|
||||
</div>
|
||||
<details id="preview-rationale" class="preview-rationale" style="margin:.25rem 0 .85rem; font-size:11px; background:var(--panel-alt); border:1px solid var(--border); padding:.55rem .7rem; border-radius:8px;">
|
||||
|
|
@ -18,7 +19,17 @@
|
|||
<span id="hover-compact-indicator" style="font-size:10px; opacity:.7;">Mode: <span data-mode>normal</span></span>
|
||||
</div>
|
||||
<ul id="rationale-points" style="margin:.5rem 0 0 .9rem; padding:0; list-style:disc; line-height:1.35;">
|
||||
<li>Computing…</li>
|
||||
{% if preview.commander_rationale and preview.commander_rationale|length > 0 %}
|
||||
{% for r in preview.commander_rationale %}
|
||||
<li>
|
||||
<strong>{{ r.label }}</strong>: {{ r.value }}
|
||||
{% if r.detail %}<span style="opacity:.75;">({{ r.detail|join(', ') }})</span>{% endif %}
|
||||
{% if r.instances %}<span style="opacity:.65;"> ({{ r.instances }} instances)</span>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<li>Computing…</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
|
@ -27,9 +38,10 @@
|
|||
<div class="col-left">
|
||||
{% if not minimal %}{% if not suppress_curated %}<h4 style="margin:.25rem 0 .5rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Example Cards</h4>{% else %}<h4 style="margin:.25rem 0 .5rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Sampled Synergy Cards</h4>{% endif %}{% endif %}
|
||||
<hr style="border:0; border-top:1px solid var(--border); margin:.35rem 0 .6rem;" />
|
||||
<div class="cards-flow" style="display:flex; flex-wrap:wrap; gap:10px;" data-synergies="{{ preview.synergies_used|join(',') if preview.synergies_used }}">
|
||||
<div class="cards-flow" style="display:flex; flex-wrap:wrap; gap:10px;" data-synergies="{{ preview.synergies_used|join(',') if preview.synergies_used }}" data-pin-scope="{{ preview.theme_id }}">
|
||||
{% set inserted = {'examples': False, 'curated_synergy': False, 'payoff': False, 'enabler_support': False, 'wildcard': False} %}
|
||||
{% for c in preview.sample if (not suppress_curated and ('example' in c.roles or 'curated_synergy' in c.roles)) or 'payoff' in c.roles or 'enabler' in c.roles or 'support' in c.roles or 'wildcard' in c.roles %}
|
||||
{% if c.dup_collapsed %}{% set dup_class = ' is-collapsed-duplicate' %}{% else %}{% set dup_class = '' %}{% endif %}
|
||||
{% set primary = c.roles[0] if c.roles else '' %}
|
||||
{% if (not suppress_curated) and 'example' in c.roles and not inserted.examples %}<div class="group-separator" data-group="examples" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.25rem;">Curated Examples</div>{% set _ = inserted.update({'examples': True}) %}{% endif %}
|
||||
{% if (not suppress_curated) and primary == 'curated_synergy' and not inserted.curated_synergy %}<div class="group-separator" data-group="curated_synergy" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Curated Synergy</div>{% set _ = inserted.update({'curated_synergy': True}) %}{% endif %}
|
||||
|
|
@ -40,11 +52,13 @@
|
|||
{% if preview.synergies_used and c.tags %}
|
||||
{% for tg in c.tags %}{% if tg in preview.synergies_used %}{% set _ = overlaps.append(tg) %}{% endif %}{% endfor %}
|
||||
{% endif %}
|
||||
<div class="card-sample{% if overlaps %} has-overlap{% endif %}" style="width:230px;" data-card-name="{{ c.name }}" data-role="{{ c.roles[0] if c.roles }}" data-reasons="{{ c.reasons|join('; ') if c.reasons }}" data-tags="{{ c.tags|join(', ') if c.tags }}" data-overlaps="{{ overlaps|join(',') }}" data-mana="{{ c.mana_cost if c.mana_cost }}" data-rarity="{{ c.rarity if c.rarity }}">
|
||||
<div class="card-sample{{ dup_class }}{% if overlaps %} has-overlap{% endif %}" style="width:230px;" data-card-name="{{ c.name }}" data-role="{{ c.roles[0] if c.roles }}" data-reasons="{{ c.reasons|join('; ') if c.reasons }}" data-tags="{{ c.tags|join(', ') if c.tags }}" data-overlaps="{{ overlaps|join(',') }}" data-mana="{{ c.mana_cost if c.mana_cost }}" data-rarity="{{ c.rarity if c.rarity }}" {% if c.dup_group_size %}data-dup-group-size="{{ c.dup_group_size }}"{% endif %} {% if c.dup_anchor %}data-dup-anchor="1"{% endif %} {% if c.dup_collapsed %}data-dup-collapsed="1" data-dup-anchor-name="{{ c.dup_anchor_name }}"{% endif %}>
|
||||
<div class="thumb-wrap" style="position:relative;">
|
||||
<img class="card-thumb" width="230" loading="lazy" decoding="async" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-role="{{ c.roles[0] if c.roles }}" data-tags="{{ c.tags|join(', ') if c.tags }}" {% if overlaps %}data-overlaps="{{ overlaps|join(',') }}"{% endif %} data-placeholder-color="#0b0d12" style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';" />
|
||||
<span class="role-chip role-{{ c.roles[0] if c.roles }}" title="Primary role: {{ c.roles[0] if c.roles }}">{{ c.roles[0][0]|upper if c.roles }}</span>
|
||||
{% if overlaps %}<span class="overlap-badge" title="Synergy overlaps: {{ overlaps|join(', ') }}">{{ overlaps|length }}</span>{% endif %}
|
||||
{% if c.dup_anchor and c.dup_group_size and c.dup_group_size > 1 %}<span class="dup-badge" title="{{ c.dup_group_size - 1 }} similar cards collapsed" style="position:absolute; bottom:4px; right:4px; background:#4b5563; color:#fff; font-size:10px; padding:2px 5px; border-radius:10px;">+{{ c.dup_group_size - 1 }}</span>{% endif %}
|
||||
<button type="button" class="pin-btn" aria-label="Pin card" title="Pin card" data-pin-btn style="position:absolute; top:4px; right:4px; background:rgba(0,0,0,0.55); color:#fff; border:1px solid var(--border); border-radius:6px; font-size:10px; padding:2px 5px; cursor:pointer;">☆</button>
|
||||
</div>
|
||||
<div class="meta" style="font-size:12px; margin-top:2px;">
|
||||
<div class="ci-ribbon" aria-label="Color identity" style="display:flex; gap:2px; margin-bottom:2px; min-height:10px;"></div>
|
||||
|
|
@ -187,6 +201,11 @@
|
|||
.theme-preview-expanded .rarity-mythic { color:#fb923c; }
|
||||
@media (max-width: 950px){ .theme-preview-expanded .two-col { grid-template-columns: 1fr; } .theme-preview-expanded .col-right { order:-1; } }
|
||||
</style>
|
||||
<style>
|
||||
.card-sample.pinned { outline:2px solid var(--accent); outline-offset:2px; }
|
||||
.card-sample .pin-btn.active { background:var(--accent); color:#000; }
|
||||
.card-sample.is-collapsed-duplicate { display:none; }
|
||||
</style>
|
||||
<script>
|
||||
// sessionStorage preview fragment cache (keyed by theme + limit + commander). Stores HTML + ETag.
|
||||
(function(){ if(document.querySelector('.theme-preview-expanded.minimal-variant')) return;
|
||||
|
|
@ -201,6 +220,68 @@
|
|||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Collapsed duplicate toggle logic (persist in localStorage global scope)
|
||||
(function(){
|
||||
try {
|
||||
var toggle = document.getElementById('show-duplicates-toggle');
|
||||
if(!toggle) return;
|
||||
var STORE_KEY = 'preview.showCollapsedDuplicates';
|
||||
function apply(){
|
||||
var show = !!toggle.checked;
|
||||
document.querySelectorAll('.card-sample.is-collapsed-duplicate').forEach(function(el){
|
||||
el.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
var saved = localStorage.getItem(STORE_KEY);
|
||||
if(saved === '1'){ toggle.checked = true; }
|
||||
apply();
|
||||
toggle.addEventListener('change', function(){
|
||||
localStorage.setItem(STORE_KEY, toggle.checked ? '1':'0');
|
||||
apply();
|
||||
});
|
||||
} catch(_){}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Client-side pin/unpin personalized examples (localStorage scoped by theme_id)
|
||||
(function(){
|
||||
try {
|
||||
var root = document.querySelector('.cards-flow[data-pin-scope]');
|
||||
if(!root) return;
|
||||
var scope = root.getAttribute('data-pin-scope');
|
||||
var storeKey = 'preview.pins.'+scope;
|
||||
function loadPins(){
|
||||
try { return JSON.parse(localStorage.getItem(storeKey) || '[]'); } catch(_) { return []; }
|
||||
}
|
||||
function savePins(pins){ try { localStorage.setItem(storeKey, JSON.stringify(pins.slice(0,100))); } catch(_){} }
|
||||
function setState(){
|
||||
var pins = loadPins();
|
||||
var cards = root.querySelectorAll('.card-sample');
|
||||
cards.forEach(function(cs){
|
||||
var name = cs.getAttribute('data-card-name');
|
||||
var btn = cs.querySelector('[data-pin-btn]');
|
||||
var pinned = pins.indexOf(name) !== -1;
|
||||
cs.classList.toggle('pinned', pinned);
|
||||
if(btn){ btn.classList.toggle('active', pinned); btn.textContent = pinned ? '★' : '☆'; btn.setAttribute('aria-label', pinned ? 'Unpin card' : 'Pin card'); }
|
||||
});
|
||||
}
|
||||
root.addEventListener('click', function(e){
|
||||
var btn = e.target.closest('[data-pin-btn]');
|
||||
if(!btn) return;
|
||||
var card = btn.closest('.card-sample');
|
||||
if(!card) return;
|
||||
var name = card.getAttribute('data-card-name');
|
||||
var pins = loadPins();
|
||||
var idx = pins.indexOf(name);
|
||||
if(idx === -1) pins.push(name); else pins.splice(idx,1);
|
||||
savePins(pins);
|
||||
setState();
|
||||
});
|
||||
setState();
|
||||
} catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Lazy-load fallback for browsers ignoring loading=lazy (very old) + intersection observer prefetch enhancement
|
||||
(function(){
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ services:
|
|||
ALLOW_MUST_HAVES: "1" # 1=enable must-include/must-exclude cards feature; 0=disable
|
||||
SHOW_MISC_POOL: "0"
|
||||
WEB_THEME_PICKER_DIAGNOSTICS: "1" # 1=enable extra theme catalog diagnostics fields, uncapped view & /themes/metrics
|
||||
# Sampling experiments
|
||||
# SPLASH_ADAPTIVE: "0" # 1=enable adaptive splash penalty scaling by commander color count
|
||||
# SPLASH_ADAPTIVE_SCALE: "1:1.0,2:1.0,3:1.0,4:0.6,5:0.35" # override default scaling
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Random Build (Alpha) Feature Flags
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ services:
|
|||
WEB_VIRTUALIZE: "1" # 1=enable list virtualization in Step 5
|
||||
ALLOW_MUST_HAVES: "1" # Include/Exclude feature enable
|
||||
WEB_THEME_PICKER_DIAGNOSTICS: "0" # 1=enable extra theme catalog diagnostics fields, uncapped synergies & /themes/metrics
|
||||
# Sampling experiments (optional)
|
||||
# SPLASH_ADAPTIVE: "0" # 1=enable adaptive splash penalty scaling by commander color count
|
||||
# SPLASH_ADAPTIVE_SCALE: "1:1.0,2:1.0,3:1.0,4:0.6,5:0.35" # override default scaling
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Random Build (Alpha) Feature Flags
|
||||
|
|
|
|||
479
logs/roadmaps/roadmap_4_5_theme_refinement.md
Normal file
479
logs/roadmaps/roadmap_4_5_theme_refinement.md
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
# Roadmap: Theme Refinement (M2.5)
|
||||
|
||||
This note captures gaps and refinements after generating `config/themes/theme_list.json` from the current tagger and constants.
|
||||
|
||||
<!--
|
||||
Roadmap Refactor (2025-09-20)
|
||||
This file was reorganized to remove duplication, unify scattered task lists, and clearly separate:
|
||||
- Completed work (historical reference)
|
||||
- Active / Remaining work (actionable backlog)
|
||||
- Deferred / Optional items
|
||||
Historical verbose phase details have been collapsed into an appendix to keep the working backlog lean.
|
||||
-->
|
||||
|
||||
## Unified Task Ledger (Single Source of Truth)
|
||||
Legend: [x]=done, [ ]=open. Each line starts with a domain tag for quick filtering.
|
||||
|
||||
### Completed (Retained for Traceability)
|
||||
[x] PHASE Extraction prototype: YAML export script, per-theme files, auto-export, fallback path
|
||||
[x] PHASE Merge pipeline: analytics regen, normalization, precedence merge, synergy cap, fallback
|
||||
[x] PHASE Validation & tests: models, schemas, validator CLI, idempotency tests, strict alias pass, CI integration
|
||||
[x] PHASE Editorial enhancements: examples & synergy commanders, augmentation heuristics, deterministic seed, description mapping, lint, popularity buckets
|
||||
[x] PHASE UI integration: picker APIs, filtering, diagnostics gating, archetype & popularity badges, stale refresh
|
||||
[x] PREVIEW Endpoint & sampling base (deterministic seed, diversity quotas, role classification)
|
||||
[x] PREVIEW Commander bias (color identity filter, overlap/theme bonuses, diminishing overlap scaling initial)
|
||||
[x] PREVIEW Curated layering (examples + curated synergy insertion ordering)
|
||||
[x] PREVIEW Caching: TTL cache, warm index build, cache bust hooks, size-limited eviction
|
||||
[x] PREVIEW UX: grouping separators, role chips, curated-only toggle, reasons collapse, tooltip <ul> restructure, color identity ribbon
|
||||
[x] PREVIEW Mana cost parsing + color pip rendering (client-side parser)
|
||||
[x] METRICS Global & per-theme avg/p95/p50 build times, request counters, role distribution, editorial coverage
|
||||
[x] LOGGING Structured preview build & cache_hit/miss, prefetch_success/error
|
||||
[x] CLIENT Perf: navigation preservation, keyboard nav, accessibility roles, lazy-load images, blur-up placeholders
|
||||
[x] CLIENT Filter chips (archetype / popularity) inline with search
|
||||
[x] CLIENT Highlight matched substrings (<mark>) in search results
|
||||
[x] CLIENT Prefetch detail fragment + top 5 likely themes (<link rel=prefetch>)
|
||||
[x] CLIENT sessionStorage preview fragment cache + ETag revalidation
|
||||
[x] FASTAPI Lifespan migration (startup deprecation removal)
|
||||
[x] FAST PATH Catalog integrity validation & catalog hash emission (drift detection)
|
||||
[x] RESILIENCE Inline retry UI for preview fetch failures (exponential backoff)
|
||||
[x] RESILIENCE Graceful degradation banner when fast path unavailable
|
||||
[x] RESILIENCE Rolling error rate counter surfaced in diagnostics
|
||||
[x] OBS Client performance marks (list_render_start, list_ready) + client hints batch endpoint
|
||||
[x] TESTS role chip rendering / prewarm metric / ordering / navigation / keyboard / accessibility / mana parser / image lazy-load / cache hit path
|
||||
[x] DOCS README API contract & examples update
|
||||
[x] FEATURE FLAG `WEB_THEME_PICKER_DIAGNOSTICS` gating fallback/editorial/uncapped
|
||||
[x] DATA Server ingestion of mana cost & rarity + normalization + pre-parsed color identity & pip caches (2025-09-20)
|
||||
[x] SAMPLING Baseline rarity & uniqueness weighting (diminishing duplicate rarity influence) (2025-09-20)
|
||||
[x] METRICS Raw curated_total & sampled_total counts per preview payload & structured logs (2025-09-20)
|
||||
[x] METRICS Global curated & sampled totals surfaced in metrics endpoint (2025-09-20)
|
||||
[x] INFRA Defensive THEME_PREVIEW_CACHE_MAX guard + warning event (2025-09-20)
|
||||
[x] BUG Theme detail: restored hover card popup panel (regression fix) (2025-09-20)
|
||||
[x] UI Hover system unified: single two-column panel (tags + overlaps) replaces legacy dual-panel + legacy large-image hover (2025-09-20)
|
||||
[x] UI Reasons control converted to checkbox with state persistence (localStorage) (2025-09-20)
|
||||
[x] UI Curated-only toggle state persistence (localStorage) (2025-09-20)
|
||||
[x] UI Commander hover parity (themes/overlaps now present for example & synergy commanders) (2025-09-20)
|
||||
[x] UI Hover panel: fragment-specific duplicate panel removed (single global implementation) (2025-09-20)
|
||||
[x] UI Hover panel: standardized large image sizing across preview modal, theme detail, build flow, and finished decks (2025-09-20)
|
||||
[x] UI Hover DFC overlay flip control (single image + top-left circular button with fade transition & keyboard support) (2025-09-20)
|
||||
[x] UI Hover DFC face persistence (localStorage; face retained across hovers & page contexts) (2025-09-20)
|
||||
[x] UI Hover immediate face refresh post-flip (no pointer synth; direct refresh API) (2025-09-20)
|
||||
[x] UI Hover stability: panel retention when moving cursor over flip button (pointerout guard) (2025-09-20)
|
||||
[x] UI Hover performance: restrict activation to thumbnail images (reduces superfluous fetches) (2025-09-20)
|
||||
[x] UI Hover image sizing & thumbnail scale increase (110px → 165px → 230px unification across preview & detail) (2025-09-20)
|
||||
[x] UI DFC UX consolidation: removed dual-image back-face markup; single img element with opacity transition (2025-09-20)
|
||||
[x] PREVIEW UX: suppress duplicated curated examples on theme detail inline preview (new suppress_curated flag) + uniform 110px card thumb sizing for consistency (2025-09-20)
|
||||
[x] PREVIEW UX: minimal inline preview variant (collapsible) removing controls/rationale/headers to reduce redundancy on detail page (2025-09-20)
|
||||
[x] BUG Theme detail: YAML fallback for description/editorial_quality/popularity_bucket restored (catalog omission regression fix) (2025-09-20)
|
||||
|
||||
### Open & Planned (Actionable Backlog) — Ordered by Priority
|
||||
|
||||
Priority Legend:
|
||||
P0 = Critical / foundational (unblocks other work or fixes regressions)
|
||||
P1 = High (meaningful UX/quality/observability improvements next wave)
|
||||
P2 = Medium (valuable but can follow P1)
|
||||
P3 = Low / Nice-to-have (consider after core goals) — many of these already in Deferred section
|
||||
|
||||
#### P0 (Immediate / Foundational & Bugs)
|
||||
[x] DATA Taxonomy snapshot tooling (`snapshot_taxonomy.py`) + initial snapshot committed (2025-09-24)
|
||||
STATUS: Provides auditable hash of BRACKET_DEFINITIONS prior to future taxonomy-aware sampling tuning.
|
||||
[x] TEST Card index color identity edge cases (hybrid, colorless/devoid, MDFC single, adventure, color indicator) (2025-09-24)
|
||||
STATUS: Synthetic CSV injected via `CARD_INDEX_EXTRA_CSV`; asserts `color_identity_list` extraction correctness.
|
||||
[x] DATA Persist parsed color identity & pips in index (remove client parsing; enable strict color filter tests) (FOLLOW-UP: expose via API for tests)
|
||||
STATUS: Server payload now exposes color_identity_list & pip_colors. REMAINING: add strict color filter tests (tracked under TEST Colors filter constraint). Client parser removal pending minor template cleanup (move to P1 if desired).
|
||||
[x] SAMPLING Commander overlap refinement (scale bonus by distinct shared synergy tags; diminishing curve)
|
||||
[x] SAMPLING Multi-color splash leniency (4–5 color commanders allow near-color enablers w/ mild penalty)
|
||||
[x] SAMPLING Role saturation penalty (discourage single-role dominance pre-synthetic)
|
||||
[x] METRICS Include curated/sample raw counts in /themes/metrics per-theme slice (per-theme raw counts)
|
||||
[x] TEST Synthetic placeholder fill (ensure placeholders inserted; roles include 'synthetic')
|
||||
[x] TEST Cache hit timing (mock clock; near-zero second build; assert cache_hit event)
|
||||
[x] TEST Colors filter constraint (colors=G restricts identities ⊆ {G} + colorless)
|
||||
[x] TEST Warm index latency reduction (cold vs warmed threshold/flag)
|
||||
[x] TEST Structured log presence (WEB_THEME_PREVIEW_LOG=1 includes duration & role_mix + raw counts)
|
||||
[x] TEST Per-theme percentile metrics existence (p50/p95 appear after multiple invocations)
|
||||
[x] INFRA Integrate rarity/mana ingestion into validator & CI lint (extend to assert normalization)
|
||||
|
||||
#### P1 (High Priority UX, Observability, Performance)
|
||||
[x] UI Picker reasons toggle parity (checkbox in list & detail contexts with persistence)
|
||||
[x] UI Export preview sample (CSV/JSON, honors curated-only toggle) — endpoints + modal export bar
|
||||
[x] UI Commander overlap & diversity rationale tooltip (bullet list distinct from reasons)
|
||||
[x] UI Scroll position restore on back navigation (prevent jump) — implemented via save/restore in picker script
|
||||
[x] UI Role badge wrapping improvements on narrow viewports (flex heuristics/min-width)
|
||||
[x] UI Truncate long theme names + tooltip in picker header row
|
||||
[x] UI-LIST Simple theme list: popularity column & quick filter (chips/dropdown) (2025-09-20)
|
||||
[x] UI-LIST Simple theme list: color filter (multi-select color identity) (2025-09-20)
|
||||
[x] UI Theme detail: enlarge card thumbnails to 230px (responsive sizing; progression 110px → 165px → 230px) (2025-09-20)
|
||||
[x] UI Theme detail: reposition example commanders below example cards (2025-09-20)
|
||||
[x] PERF Adaptive TTL/eviction tuning (hit-rate informed bounded adjustment) — adaptive TTL completed; eviction still FIFO (partial)
|
||||
[x] PERF Background refresh top-K hot themes on interval (threaded warm of top request slugs)
|
||||
[x] RESILIENCE Mitigate FOUC on first detail load (inline critical CSS / preload) (2025-09-20)
|
||||
[x] RESILIENCE Abort controller enforcement for rapid search (cancel stale responses) (2025-09-20)
|
||||
[x] RESILIENCE Disable preview refresh button during in-flight fetch (2025-09-20)
|
||||
[x] RESILIENCE Align skeleton layout commander column (cross-browser flex baseline) (2025-09-20)
|
||||
[x] METRICS CLI snapshot utility (scripts/preview_metrics_snapshot.py) global + top N slow themes (2025-09-20)
|
||||
[x] CATALOG Decide taxonomy expansions & record rationale (Combo, Storm, Extra Turns, Group Hug/Politics, Pillowfort, Toolbox/Tutors, Treasure Matters, Monarch/Initiative) (2025-09-20)
|
||||
[x] CATALOG Apply accepted new themes (YAML + normalization & whitelist updates) (2025-09-20)
|
||||
[x] CATALOG Merge/normalize duplicates (ETB wording, Board Wipes variants, Equipment vs Equipment Matters, Auras vs Enchantments Matter) + diff report (2025-09-20)
|
||||
[x] GOVERNANCE Enforce example count threshold (flip from optional once coverage met) (2025-09-20)
|
||||
STATUS: Threshold logic & policy documented; enforcement switch gated on coverage metric (>90%).
|
||||
[x] DOCS Contributor diff diagnostics & validation failure modes section (2025-09-20)
|
||||
[x] DOCS Editorial governance note for multi-color splash relax policy (2025-09-20)
|
||||
[x] CATALOG Expose advanced uncapped synergy mode outside diagnostics (config guarded) (2025-09-20)
|
||||
|
||||
#### P2 (Medium / Follow-On Enhancements)
|
||||
[x] UI Hover compact mode toggle (reduced image & condensed metadata) (2025-09-20)
|
||||
[x] UI Hover keyboard accessibility (focus traversal / ESC dismiss / ARIA refinement) (2025-09-20)
|
||||
[x] UI Hover image prefetch & small LRU cache (reduce repeat fetch latency) (2025-09-20)
|
||||
[x] UI Hover optional activation delay (~120ms) to reduce flicker on rapid movement (2025-09-20)
|
||||
[x] UI Hover enhanced overlap highlighting (multi-color or badge styling vs single accent) (2025-09-20)
|
||||
[x] DATA Externalize curated synergy pair matrix to data file (loader added; file optional) (2025-09-20)
|
||||
[x] UI Commander overlap & diversity rationale richer analytics (spread index + compact mode state) (2025-09-20)
|
||||
[x] SAMPLING Additional fine-tuning after observing rarity weighting impact (env-calibrated rarity weights + reasons tag) (2025-09-20)
|
||||
[x] PERF Further background refresh heuristics (adaptive interval by error rate / p95 latency) (2025-09-20)
|
||||
[x] RESILIENCE Additional race condition guard: preview empty panel during cache bust (retry w/backoff) (2025-09-20)
|
||||
[x] DOCS Expanded editorial workflow & PR checklist (placeholder – to be appended in governance doc follow-up) (2025-09-20)
|
||||
[x] CATALOG Advanced uncapped synergy mode docs & governance guidelines (already documented earlier; reaffirmed) (2025-09-20)
|
||||
[x] OBS Optional: structured per-theme error histogram in metrics endpoint (per_theme_errors + retry log) (2025-09-20)
|
||||
|
||||
#### P3 (Move to Deferred if low traction)
|
||||
(See Deferred / Optional section for remaining low-priority or nice-to-have items)
|
||||
|
||||
### Deferred / Optional (Lower Priority)
|
||||
[x] OPTIONAL Extended rarity diversity target (dynamic quotas) (2025-09-24) — implemented via env RARITY_DIVERSITY_TARGETS + overflow penalty RARITY_DIVERSITY_OVER_PENALTY
|
||||
[ ] OPTIONAL Price / legality snippet integration (Deferred – see `logs/roadmaps/roadmap_9_budget_mode.md`)
|
||||
[x] OPTIONAL Duplicate synergy collapse / summarization heuristic (2025-09-24) — implemented heuristic grouping: identical (>=2) synergy overlap sets + same primary role collapse; anchor shows +N badge; toggle to reveal all; non-destructive metadata fields dup_anchor/dup_collapsed.
|
||||
[x] OPTIONAL Client-side pin/unpin personalized examples (2025-09-24) — localStorage pins with button UI in preview_fragment
|
||||
[x] OPTIONAL Export preview as deck seed directly to build flow (2025-09-24) — endpoint /themes/preview/{theme_id}/export_seed.json
|
||||
[x] OPTIONAL Service worker offline caching (theme list + preview fragments) (2025-09-24) — implemented `sw.js` with catalog hash versioning (?v=<catalog_hash>) precaching core shell (/, /themes/, styles, app.js, manifest, favicon) and runtime stale-while-revalidate cache for theme list & preview fragment requests. Added `catalog_hash` exposure in Jinja globals for SW version bump / auto invalidation; registration logic auto reloads on new worker install. Test `test_service_worker_offline.py` asserts presence of versioned registration and SW script serving.
|
||||
[x] OPTIONAL Multi-color splash penalty tuning analytics loop (2025-09-24) — added splash analytics counters (splash_off_color_total_cards, splash_previews_with_penalty, splash_penalty_reason_events) + structured log fields (splash_off_color_cards, splash_penalty_events) for future adaptive tuning.
|
||||
[x] OPTIONAL Ratchet proposal PR comment bot (description fallback regression suggestions) (2025-09-24) — Added GitHub Actions step in `editorial_governance.yml` posting/updating a structured PR comment with proposed new ceilings derived from `ratchet_description_thresholds.py`. Comment includes diff snippet for updating `test_theme_description_fallback_regression.py`, rationale list, and markers (`<!-- ratchet-proposal:description-fallback -->`) enabling idempotent updates.
|
||||
[x] OPTIONAL Enhanced commander overlap rationale (structured multi-factor breakdown) (2025-09-24) — server now emits commander_rationale array (synergy spread, avg overlaps, role diversity score, theme match bonus, overlap bonus aggregate, splash leniency count) rendered directly in rationale list.
|
||||
|
||||
### Open Questions (for Future Decisions)
|
||||
[ ] Q Should taxonomy expansion precede rarity weighting (frequency impact)?
|
||||
[ ] Q Require server authoritative mana & color identity before advanced overlap refinement? (likely yes)
|
||||
[ ] Q Promote uncapped synergy mode from diagnostics when governance stabilizes?
|
||||
[ ] Q Splash relax penalty: static constant vs adaptive based on color spread?
|
||||
|
||||
Follow-Up (New Planned Next Steps 2025-09-24):
|
||||
- [x] SAMPLING Optional adaptive splash penalty flag (`SPLASH_ADAPTIVE=1`) reading commander color count to scale penalty (2025-09-24)
|
||||
STATUS: Implemented scaling via `parse_splash_adaptive_scale()` with default spec `1:1.0,2:1.0,3:1.0,4:0.6,5:0.35`. Adaptive reasons emitted as `splash_off_color_penalty_adaptive:<colors>:<value>`.
|
||||
- [x] TEST Adaptive splash penalty scaling unit test (`test_sampling_splash_adaptive.py`) (2025-09-24)
|
||||
- [ ] METRICS Splash adaptive experiment counters (compare static vs adaptive deltas) (Pending – current metrics aggregate penalty events but not separated by adaptive vs static.)
|
||||
- [x] DOCS Add taxonomy snapshot process & rationale section to README governance appendix. (2025-09-24)
|
||||
|
||||
### Exit Criteria (Phase F Completion)
|
||||
[x] EXIT Rarity weighting baseline + overlap refinement + splash policy implemented (2025-09-23)
|
||||
[x] EXIT Server-side mana/rarity ingestion complete (client heuristics removed) (2025-09-23) – legacy client mana & color identity parsers excised (`preview_fragment.html`) pending perf sanity
|
||||
[x] EXIT Test suite covers cache timing, placeholders, color constraints, structured logs, percentile metrics (2025-09-23) – individual P0 test items all green
|
||||
[x] EXIT p95 preview build time stabilized under target post-ingestion (2025-09-23) – warm p95 11.02ms (<60ms tightened target) per `logs/perf/theme_preview_baseline_warm.json`
|
||||
[x] EXIT Observability includes raw curated/sample counts + snapshot tooling (2025-09-23)
|
||||
[x] EXIT UX issues (FOUC, scroll restore, flicker, wrapping) mitigated (2025-09-23)
|
||||
|
||||
#### Remaining Micro Tasks (Phase F Close-Out)
|
||||
[x] Capture & commit p95 warm baseline (v2 & v3 warm snapshots captured; tightened target <60ms p95 achieved) (2025-09-23)
|
||||
[x] Define enforcement flag activation event for example coverage (>90%) and log metric (2025-09-23) – exposed `example_enforcement_active` & `example_enforce_threshold_pct` in `preview_metrics()`
|
||||
[x] Kick off Core Refactor Phase A (extract `preview_cache.py`, `sampling.py`) with re-export shim – initial extraction (metrics remained then; adaptive TTL & bg refresh now migrated) (2025-09-23)
|
||||
[x] Add focused unit tests for sampling (overlap bonus monotonicity, splash penalty path, rarity diminishing) post-extraction (2025-09-23)
|
||||
|
||||
### Core Refactor Phase A – Task Checklist (No Code Changes Yet)
|
||||
Planning & Scaffolding:
|
||||
[x] Inventory current `theme_preview.py` responsibilities (annotated in header docstring & inline comments) (2025-09-23)
|
||||
[x] Define public API surface contract (get_theme_preview, preview_metrics, bust_preview_cache) docstring block (present in file header) (2025-09-23)
|
||||
[x] Create placeholder modules (`preview_cache.py`, `sampling.py`) with docstring and TODO markers – implemented (2025-09-23)
|
||||
[x] Introduce `card_index` concerns inside `sampling.py` (temporary; will split to `card_index.py` in next extraction step) (2025-09-23)
|
||||
|
||||
Extraction Order:
|
||||
[x] Extract pure data structures / constants (scores, rarity weights) to `sampling.py` (2025-09-23)
|
||||
[x] Extract card index build & lookup helpers (initially retained inside `sampling.py`; dedicated `card_index.py` module planned) (2025-09-23)
|
||||
[x] Extract cache dict container to `preview_cache.py` (adaptive TTL + bg refresh still in `theme_preview.py`) (2025-09-23)
|
||||
[x] Add re-export imports in `theme_preview.py` to preserve API stability (2025-09-23)
|
||||
[x] Run focused unit tests post-extraction (sampling unit tests green) (2025-09-23)
|
||||
|
||||
Post-Extraction Cleanup:
|
||||
[x] Remove deprecated inline sections from monolith (sampling duplicates & card index removed; adaptive TTL now migrated) (2025-09-23)
|
||||
[x] Add mypy types for sampling pipeline inputs/outputs (TypedDict `SampledCard` added) (2025-09-23)
|
||||
[x] Write new unit tests: rarity diminishing, overlap scaling, splash leniency (added) (2025-09-23) (role saturation penalty test still optional)
|
||||
[x] Update roadmap marking Phase A partial vs complete (this update) (2025-09-23)
|
||||
[x] Capture LOC reduction metrics (before/after counts) in `logs/perf/theme_preview_refactor_loc.md` (2025-09-23)
|
||||
|
||||
Validation & Performance:
|
||||
[x] Re-run performance snapshot after refactor (ensure no >5% regression p95) – full catalog single-pass baseline (`theme_preview_baseline_all_pass1_20250923.json`) + multi-pass run (`theme_preview_all_passes2.json`) captured; warm p95 within +<5% target (warm pass p95 38.36ms vs baseline p95 36.77ms, +4.33%); combined (cold+warm) p95 +5.17% noted (acceptable given cold inclusion). Tooling enhanced with `--extract-warm-baseline` and comparator `--warm-only --p95-threshold` for CI gating (2025-09-23)
|
||||
FOLLOW-UP (completed 2025-09-23): canonical CI threshold adopted (fail if warm-only p95 delta >5%) & workflow `.github/workflows/preview-perf-ci.yml` invokes wrapper to enforce.
|
||||
[x] Verify background refresh thread starts post-migration (log inspection + `test_preview_bg_refresh_thread.py`) (2025-09-23)
|
||||
[x] Verify adaptive TTL events emitted (added `test_preview_ttl_adaptive.py`) (2025-09-23)
|
||||
|
||||
---
|
||||
## Refactor Objectives & Workplans (Added 2025-09-20)
|
||||
|
||||
We are introducing structured workplans for: Refactor Core (A), Test Additions (C), JS & Accessibility Extraction (D). Letters map to earlier action menu.
|
||||
|
||||
### A. Core Refactor (File Size, Modularity, Maintainability)
|
||||
Current Pain Points:
|
||||
- `code/web/services/theme_preview.py` (~32K lines added) monolithic: caching, sampling, scoring, rarity logic, commander heuristics, metrics, background refresh intermixed.
|
||||
- `code/web/services/theme_catalog_loader.py` large single file (catalog IO, filtering, validation, metrics, prewarm) — logically separable.
|
||||
- Oversized test files (`code/tests/test_theme_preview_p0_new.py`, `code/tests/test_theme_preview_ordering.py`) contain a handful of tests but thousands of blank lines (bloat).
|
||||
- Inline JS in templates (`picker.html`, `preview_fragment.html`) growing; hard to lint / unit test.
|
||||
|
||||
Refactor Goals:
|
||||
1. Reduce each service module to focused responsibilities (<800 lines per file target for readability).
|
||||
2. Introduce clear internal module boundaries with stable public functions (minimizes future churn for routes & tests).
|
||||
3. Improve testability: smaller units + isolated pure functions for scoring & sampling.
|
||||
4. Prepare ground for future adaptive eviction (will slot into new cache module cleanly).
|
||||
5. Eliminate accidental file bloat (trim whitespace, remove duplicate blocks) without semantic change.
|
||||
|
||||
Proposed Module Decomposition (Phase 1 – no behavior change):
|
||||
- `code/web/services/preview_cache.py`
|
||||
- Responsibilities: in-memory OrderedDict cache, TTL adaptation, background refresh thread, metrics aggregation counters, `bust_preview_cache`, `preview_metrics` (delegated).
|
||||
- Public API: `get_cached(slug, key)`, `store_cached(slug, key, payload)`, `record_build(ms, curated_count, role_counts, slug)`, `maybe_adapt_ttl()`, `ensure_bg_thread()`, `preview_metrics()`.
|
||||
- `code/web/services/card_index.py`
|
||||
- Card CSV ingestion, normalization (rarity, mana, color identity lists, pip extraction).
|
||||
- Public API: `maybe_build_index()`, `lookup_commander(name)`, `get_tag_pool(theme)`.
|
||||
- `code/web/services/sampling.py`
|
||||
- Deterministic seed, card role classification, scoring (including commander overlap scaling, rarity weighting, splash penalties, role saturation, diversity quotas), selection pipeline returning list of chosen cards (no cache concerns).
|
||||
- Public API: `sample_cards(theme, synergies, limit, colors_filter, commander)`.
|
||||
- `code/web/services/theme_preview.py` (after extraction)
|
||||
- Orchestrator: assemble detail (via existing catalog loader), call sampling, layer curated examples, synth placeholders, integrate cache, build payload.
|
||||
- Public API remains: `get_theme_preview`, `preview_metrics`, `bust_preview_cache` (re-export from submodules for backward compatibility).
|
||||
|
||||
Phase 2 (optional, after stabilization):
|
||||
- Extract adaptive TTL policy into `preview_policy.py` (so experimentation with hit-ratio bands is isolated).
|
||||
- Add interface / protocol types for cache backends (future: Redis experimentation).
|
||||
|
||||
Test Impact Plan:
|
||||
- Introduce unit tests for `sampling.sample_cards` (roles distribution, rarity diminishing, commander overlap bonus monotonic increase with overlap count, splash penalty trigger path).
|
||||
- Add unit tests for TTL adaptation thresholds with injected recent hits deque.
|
||||
|
||||
Migration Steps (A):
|
||||
1. Create new modules with copied (not yet deleted) logic; add thin wrappers in old file calling new functions.
|
||||
2. Run existing tests to confirm parity.
|
||||
3. Remove duplicated logic from legacy monolith; leave deprecation comments.
|
||||
4. Trim oversized test files to only necessary lines (reformat into logical groups).
|
||||
5. Add mypy-friendly type hints between modules (use `TypedDict` or small dataclasses for card item shape if helpful).
|
||||
6. Update roadmap: mark refactor milestone complete when file LOC & module boundaries achieved.
|
||||
|
||||
Acceptance Criteria (A):
|
||||
- All existing endpoints unchanged.
|
||||
- No regressions in preview build time (baseline within ±5%).
|
||||
- Test suite green; new unit tests added.
|
||||
- Adaptive TTL + background refresh still functional (logs present).
|
||||
|
||||
### Refactor Progress Snapshot (2025-09-23)
|
||||
Refactor Goals Checklist (Phase A):
|
||||
Refactor Goals Checklist (Phase A):
|
||||
- [x] Goal 1 (<800 LOC per module) — current LOC: `theme_preview.py` ~525, `sampling.py` 241, `preview_cache.py` ~140, `card_index.py` ~200 (all below threshold; monolith reduced dramatically).
|
||||
- [x] Goal 2 Module boundaries & stable public API (`__all__` exports maintained; re-export shim present).
|
||||
- [x] Goal 3 Testability improvements — new focused sampling tests (overlap monotonicity, splash penalty, rarity diminishing). Optional edge-case tests deferred.
|
||||
- [x] Goal 4 Adaptive eviction & backend abstraction implemented (2025-09-24) — heuristic scoring + metrics + overflow guard + backend interface extracted.
|
||||
- [x] Goal 5 File bloat eliminated — duplicated blocks & legacy inline logic removed; large helpers migrated.
|
||||
|
||||
Phase 1 Decomposition Checklist:
|
||||
- [x] Extract `preview_cache.py` (cache container + TTL adaptation + bg refresh)
|
||||
- [x] Extract `sampling.py` (sampling & scoring pipeline)
|
||||
- [x] Extract `card_index.py` (CSV ingestion & normalization)
|
||||
- [x] Retain orchestrator in `theme_preview.py` (now focused on layering + metrics + cache usage)
|
||||
- [x] Deduplicate role helpers (`_classify_role`, `_seed_from`) (helpers removed from `theme_preview.py`; authoritative versions reside in `sampling.py`) (2025-09-23)
|
||||
|
||||
Phase 2 (In Progress):
|
||||
Phase 2 (Completed 2025-09-24):
|
||||
- [x] Extract adaptive TTL policy tuning constants to `preview_policy.py` (2025-09-23)
|
||||
- [x] Introduce cache backend interface (protocol) for potential Redis experiment (2025-09-23) — `preview_cache_backend.py`
|
||||
- [x] Separate metrics aggregation into `preview_metrics.py` (2025-09-23)
|
||||
- [x] Scoring constants / rarity weights module (`sampling_config.py`) for cleaner tuning surface (2025-09-23)
|
||||
- [x] Implement adaptive eviction strategy (hit-ratio + recency + cost hybrid) & tests (2025-09-23)
|
||||
- [x] Add CI perf regression check (warm-only p95 threshold) (2025-09-23) — implemented via `.github/workflows/preview-perf-ci.yml` (fails if warm p95 delta >5%)
|
||||
- [x] Multi-pass CI variant flag (`--multi-pass`) for cold/warm differential diagnostics (2025-09-24)
|
||||
|
||||
Performance & CI Follow-Ups:
|
||||
- [x] Commit canonical warm baseline produced via `--extract-warm-baseline` into `logs/perf/` (`theme_preview_warm_baseline.json`) (2025-09-23)
|
||||
- [x] Add CI helper script wrapper (`preview_perf_ci_check.py`) to generate candidate + compare with threshold (2025-09-23)
|
||||
- [x] Add GitHub Actions / task invoking wrapper: `python -m code.scripts.preview_perf_ci_check --baseline logs/perf/theme_preview_warm_baseline.json --p95-threshold 5` (2025-09-23) — realized in workflow `preview-perf-ci`
|
||||
- [x] Document perf workflow in `README.md` (section: Performance Baselines & CI Gate) (2025-09-23)
|
||||
- [x] (Optional) Provide multi-pass variant option in CI (flag) if future warm-only divergence observed (2025-09-23)
|
||||
- [x] Add CHANGELOG entry formalizing performance gating policy & warm baseline refresh procedure (criteria: intentional improvement >10% p95 OR drift >5% beyond tolerance) (2025-09-24) — consolidated with Deferred Return Tasks section entry
|
||||
|
||||
Open Follow-Ups (Minor / Opportunistic):
|
||||
Open Follow-Ups (Minor / Opportunistic):
|
||||
- [x] Role saturation penalty dedicated unit test (2025-09-23)
|
||||
- [x] card_index edge-case test (rarity normalization & duplicate name handling) (2025-09-23)
|
||||
- [x] Consolidate duplicate role/hash helpers into sampling (2025-09-24)
|
||||
- [x] Evaluate moving commander bias constants to config module for easier tuning (moved to `sampling_config.py`, imports updated) (2025-09-23)
|
||||
- [x] Add regression test: Scryfall query normalization strips synergy annotations (image + search URLs) (2025-09-23)
|
||||
|
||||
Status Summary (Today): Phase A decomposition effectively complete; only minor dedup & optional tests outstanding. Phase 2 items queued; performance tooling & baseline captured enabling CI regression gate next. Synergy annotation Scryfall URL normalization bug fixed across templates & global JS (2025-09-23); regression test pending.
|
||||
|
||||
Recent Change Note (2025-09-23): Added cache entry metadata (hit_count, last_access, build_cost_ms) & logging of cache hits. Adjusted warm latency test with guard for near-zero cold timing to reduce flakiness post-cache instrumentation.
|
||||
|
||||
### Phase 2 Progress (2025-09-23 Increment)
|
||||
- [x] Extract adaptive TTL policy tuning constants to `preview_policy.py` (no behavior change; unit tests unaffected)
|
||||
FOLLOW-UP: add env overrides & validation tests for bands/steps (new deferred task)
|
||||
|
||||
### Adaptive Eviction Plan (Kickoff 2025-09-23)
|
||||
Goal: Replace current FIFO size-limited eviction with an adaptive heuristic combining recency, hit frequency, and rebuild cost to maximize effective hit rate while minimizing expensive rebuild churn.
|
||||
|
||||
Data Model Additions (per cache entry):
|
||||
- inserted_at_ms (int)
|
||||
- last_access_ms (int) — update on each hit
|
||||
- hit_count (int)
|
||||
- build_cost_ms (int) — capture from metrics when storing
|
||||
- slug (theme identifier) + key (variant) retained
|
||||
|
||||
Heuristic (Evict lowest ProtectionScore):
|
||||
ProtectionScore = (W_hits * log(1 + hit_count)) + (W_recency * recency_score) + (W_cost * cost_bucket) - (W_age * age_score)
|
||||
Where:
|
||||
- recency_score = 1 / (1 + minutes_since_last_access)
|
||||
- age_score = minutes_since_inserted
|
||||
- cost_bucket = 0..3 derived from build_cost_ms thresholds (e.g. <5ms=0, <15ms=1, <40ms=2, >=40ms=3)
|
||||
- Weights default (tunable via env): W_hits=3.0, W_recency=2.0, W_cost=1.0, W_age=1.5
|
||||
|
||||
Algorithm:
|
||||
1. On insertion when size > MAX: build candidate list (all entries OR bounded sample if size > SAMPLE_THRESHOLD).
|
||||
2. Compute ProtectionScore for each candidate.
|
||||
3. Evict N oldest/lowest-score entries until size <= MAX (normally N=1, loop in case of concurrent overshoot).
|
||||
4. Record eviction event metric with reason fields: {hit_count, age_ms, build_cost_ms, protection_score}.
|
||||
|
||||
Performance Safeguards:
|
||||
- If cache size > 2 * MAX (pathological), fall back to age-based eviction ignoring scores (O(n) guard path) and emit warning metric.
|
||||
- Optional SAMPLE_TOP_K (default disabled). When enabled and size > 2*MAX, sample K random entries + oldest X to bound calculation time.
|
||||
|
||||
Environment Variables (planned additions):
|
||||
- THEME_PREVIEW_EVICT_W_HITS / _W_RECENCY / _W_COST / _W_AGE
|
||||
- THEME_PREVIEW_EVICT_COST_THRESHOLDS (comma list e.g. "5,15,40")
|
||||
- THEME_PREVIEW_EVICT_SAMPLE_THRESHOLD (int) & THEME_PREVIEW_EVICT_SAMPLE_SIZE (int)
|
||||
|
||||
Metrics Additions (`preview_metrics.py`):
|
||||
- eviction_total (counter)
|
||||
- eviction_by_reason buckets (low_score, emergency_overflow)
|
||||
- eviction_last (gauge snapshot of last event metadata)
|
||||
- eviction_hist_build_cost_ms (distribution)
|
||||
|
||||
Testing Plan:
|
||||
1. test_eviction_prefers_low_hit_old_entries: create synthetic entries with varying hit_count/age; assert low score evicted.
|
||||
2. test_eviction_protects_hot_recent: recent high-hit entry retained when capacity exceeded.
|
||||
3. test_eviction_cost_bias: two equally old entries different build_cost_ms; cheaper one evicted.
|
||||
4. test_eviction_emergency_overflow: simulate size >2*MAX triggers age-only path and emits warning metric.
|
||||
5. test_eviction_metrics_emitted: store then force eviction; assert counters increment & metadata present.
|
||||
|
||||
Implementation Steps (Ordered):
|
||||
1. Extend cache entry structure in `preview_cache.py` (introduce metadata fields) (IN PROGRESS 2025-09-23 ✅ base dict metadata: inserted_at, last_access, hit_count, build_cost_ms).
|
||||
2. Capture build duration (already known at store time) into entry.build_cost_ms. (✅ implemented via store_cache_entry)
|
||||
3. Update get/store paths to mutate hit_count & last_access_ms.
|
||||
4. Add weight & threshold resolution helper (reads env once; cached, with reload guard for tests). (✅ implemented: _resolve_eviction_weights / _resolve_cost_thresholds / compute_protection_score)
|
||||
5. Implement `_compute_protection_score(entry, now_ms)`.
|
||||
6. Implement `_evict_if_needed()` invoked post-store under lock.
|
||||
7. Wire metrics recording & add to `preview_metrics()` export.
|
||||
8. Write unit tests with small MAX (e.g. set THEME_PREVIEW_CACHE_MAX=5) injecting synthetic entries via public API or helper. (IN PROGRESS: basic low-score eviction test added `test_preview_eviction_basic.py`; remaining: cost bias, hot retention, emergency overflow, metrics detail test)
|
||||
9. Benchmark warm p95 to confirm <5% regression (update baseline if improved).
|
||||
10. Update roadmap & CHANGELOG (add feature note) once tests green.
|
||||
|
||||
Acceptance Criteria:
|
||||
- All new tests green; no regression in existing preview tests.
|
||||
- Eviction events observable via metrics endpoint & structured logs.
|
||||
- Warm p95 delta within ±5% of baseline (or improved) post-feature.
|
||||
- Env weight overrides respected (smoke test via one test toggling W_HITS=0 to force different eviction order).
|
||||
|
||||
Progress Note (2025-09-23): Steps 5-7 implemented (protection score via `compute_protection_score`, adaptive `evict_if_needed`, eviction metrics + structured log). Basic eviction test passing. Remaining tests & perf snapshot pending.
|
||||
|
||||
Progress Update (2025-09-23 Later): Advanced eviction tests added & green:
|
||||
- test_preview_eviction_basic.py (low-score eviction)
|
||||
- test_preview_eviction_advanced.py (cost bias retention, hot entry retention, emergency overflow path trigger, env weight override)
|
||||
Phase 2 Step 8 now complete (full test coverage for initial heuristic). Next: Step 9 performance snapshot (warm p95 delta check <5%) then CHANGELOG + roadmap close-out for eviction feature (Step 10). Added removal of hard 50-entry floor in `evict_if_needed` to allow low-limit tests; operational deployments can enforce higher floor via env. No existing tests regressed.
|
||||
|
||||
Additional Progress (2025-09-23): Added `test_scryfall_name_normalization.py` ensuring synergy annotation suffix is stripped; roadmap follow-up item closed.
|
||||
|
||||
Deferred (Post-MVP) Ideas:
|
||||
- Protect entries with curated_only flag separately (bonus weight) if evidence of churn emerges.
|
||||
- Adaptive weight tuning based on rolling hit-rate KPI.
|
||||
- Redis backend comparative experiment using same scoring logic.
|
||||
|
||||
|
||||
### C. Test Additions (Export Endpoints & Adaptive TTL)
|
||||
Objectives:
|
||||
1. Validate `/themes/preview/{theme}/export.json` & `.csv` endpoints (status 200, field completeness, curated_only filter semantics).
|
||||
2. Validate CSV header column order is stable.
|
||||
3. Smoke test adaptive TTL event emission (simulate hit/miss pattern to cross a band and assert printed `theme_preview_ttl_adapt`).
|
||||
4. Increase preview coverage for curated_only filtering (confirm role exclusion logic matching examples + curated synergy only).
|
||||
|
||||
Test Files Plan:
|
||||
- New `code/tests/test_preview_export_endpoints.py`:
|
||||
- Parametrized theme slug (pick first theme from index) to avoid hard-coded `Blink` dependency.
|
||||
- JSON export: assert keys subset {name, roles, score, rarity, mana_cost, color_identity_list, pip_colors}.
|
||||
- curated_only=1: assert no sampled roles in roles set {payoff,enabler,support,wildcard}.
|
||||
- CSV export: parse first line for header stability.
|
||||
- New `code/tests/test_preview_ttl_adaptive.py`:
|
||||
- Monkeypatch `_ADAPTATION_ENABLED = True`, set small window, inject sequence of hits/misses by calling `get_theme_preview` & optionally direct manipulation of deque if needed.
|
||||
- Capture stdout; assert adaptation log appears with expected event.
|
||||
|
||||
Non-Goals (C):
|
||||
- Full statistical validation of score ordering (belongs in sampling unit tests under refactor A).
|
||||
- Integration latency benchmarks (future optional performance tests).
|
||||
|
||||
### D. JS Extraction & Accessibility Improvements
|
||||
Objectives:
|
||||
1. Move large inline scripts from `picker.html` & `preview_fragment.html` into static JS files for linting & reuse.
|
||||
2. Add proper modal semantics & focus management (role="dialog", aria-modal, focus trap, ESC close, return focus to invoker after close).
|
||||
3. Implement AbortController in search (cancel previous fetch) and disable refresh button while a preview fetch is in-flight.
|
||||
4. Provide minimal build (no bundler) using plain ES modules—keep dependencies zero.
|
||||
|
||||
Planned Files:
|
||||
- `code/web/static/js/theme_picker.js`
|
||||
- `code/web/static/js/theme_preview_modal.js`
|
||||
- (Optional) `code/web/static/js/util/accessibility.js` (trapFocus, restoreFocus helpers)
|
||||
|
||||
Implementation Steps (D):
|
||||
1. Extract current inline JS blocks preserving order; wrap in IIFEs exported as functions if needed.
|
||||
2. Add `<script type="module" src="/static/js/theme_picker.js"></script>` in `base.html` or only on picker route template.
|
||||
3. Replace inline modal creation with accessible structure:
|
||||
- Add container with `role="dialog" aria-labelledby="preview-heading" aria-modal="true"`.
|
||||
- On open: store activeElement, focus first focusable (close button).
|
||||
- On ESC or close: remove modal & restore focus.
|
||||
4. AbortController: hold reference in closure; on new search input, abort prior, then issue new fetch.
|
||||
5. Refresh button disable: set `disabled` + aria-busy while fetch pending; re-enable on completion or failure.
|
||||
6. Add minimal accessibility test (JS-free fallback: ensure list still renders). (Optional for now.)
|
||||
|
||||
Acceptance Criteria (D):
|
||||
- Picker & preview still function identically (manual smoke).
|
||||
- Lighthouse / axe basic scan passes (no blocking dialog issues, focus trap working).
|
||||
- Inline JS in templates reduced to <30 lines (just bootstrapping if any).
|
||||
|
||||
### Cross-Cutting Risks & Mitigations
|
||||
- Race conditions during refactor: mitigate by staged copy, then delete.
|
||||
- Thread interactions (background refresh) in tests: set `THEME_PREVIEW_BG_REFRESH=0` within test environment to avoid nondeterminism.
|
||||
- Potential path import churn: maintain re-export surface from `theme_preview.py` until downstream usages updated.
|
||||
|
||||
### Tracking
|
||||
Add a new section in future updates summarizing A/C/D progress deltas; mark each Acceptance Criteria bullet as met with date.
|
||||
|
||||
---
|
||||
|
||||
### Progress (2025-09-20 Increment)
|
||||
- Implemented commander overlap & diversity rationale tooltip (preview modal). Added dynamic list computing role distribution, distinct synergy overlaps, average overlaps, diversity heuristic score, curated share. Marked item complete in P1.
|
||||
- Added AbortController cancellation for rapid search requests in picker (resilience improvement).
|
||||
- Implemented simple list popularity quick filters (chips + select) and color identity multi-select filtering.
|
||||
- Updated theme detail layout: enlarged example card thumbnails and moved commander examples below cards (improves scan order & reduces vertical jump).
|
||||
- Mitigated FOUC and aligned skeleton layout; preview refresh now disabled while list fetch in-flight.
|
||||
- Added metrics snapshot CLI utility `code/scripts/preview_metrics_snapshot.py` (captures global + top N slow themes).
|
||||
- Catalog taxonomy rationale documented (`docs/theme_taxonomy_rationale.md`); accepted themes annotated and duplicates normalization logged.
|
||||
- Governance & editorial policies (examples threshold, splash relax policy) added to README and taxonomy rationale; enforcement gating documented.
|
||||
- Contributor diagnostics & validation failure modes section added (README governance segment + rationale doc).
|
||||
- Uncapped synergy mode exposure path documented & config guard clarified.
|
||||
|
||||
|
||||
### Success Metrics (Reference)
|
||||
[x] METRIC Metadata_info coverage >=99% (achieved)
|
||||
[ ] METRIC Generic fallback description KPI trending down per release window (continue tracking)
|
||||
[ ] METRIC Warmed preview median & p95 under established thresholds after ingestion (record baseline then ratchet)
|
||||
|
||||
---
|
||||
This unified ledger supersedes all prior phased or sectional lists. Historical narrative available via git history if needed.
|
||||
|
||||
### Deferral Notes (Added 2025-09-24)
|
||||
The Price / legality snippet integration is deferred and will be handled holistically in the Budget Mode initiative (`roadmap_9_budget_mode.md`) to centralize price sourcing (API selection, caching, rate limiting), legality checks, and UI surfaces. This roadmap will only re-introduce a lightweight read-only badge if an interim need emerges.
|
||||
\n+### Newly Deferred Return Tasks (Added 2025-09-23)
|
||||
### Newly Deferred Return Tasks (Added 2025-09-23) (Updated 2025-09-24)
|
||||
[x] POLICY Env overrides for TTL bands & step sizes + tests (2025-09-24) — implemented via env parsing in `preview_policy.py` (`THEME_PREVIEW_TTL_BASE|_MIN|_MAX`, `THEME_PREVIEW_TTL_BANDS`, `THEME_PREVIEW_TTL_STEPS`)
|
||||
[x] PERF Multi-pass CI variant toggle (enable warm/cold delta diagnostics when divergence suspected) (2025-09-24)
|
||||
[x] CACHE Introduce backend interface & in-memory implementation wrapper (prep for Redis experiment) (2025-09-23)
|
||||
[x] CACHE Redis backend PoC + latency/CPU comparison & fallback logic (2025-09-24) — added `preview_cache_backend.py` optional Redis read/write-through (env THEME_PREVIEW_REDIS_URL). Memory remains source of truth; Redis used opportunistically on memory miss. Metrics expose redis_get_attempts/hits/errors & store_attempts/errors. Graceful fallback when library/connection absent verified via `test_preview_cache_redis_poc.py`.
|
||||
[x] DOCS CHANGELOG performance gating policy & baseline refresh procedure (2025-09-24)
|
||||
[x] SAMPLING Externalize scoring & rarity weights to `sampling_config.py` (2025-09-23)
|
||||
[x] METRICS Extract `preview_metrics.py` module (2025-09-23)
|
||||
Loading…
Add table
Add a link
Reference in a new issue