ML models: walk-forward-judged panel rank, plain-language ML disclosure in report and progress
Browse files- analyst.py +1 -1
- engine/brief.py +3 -1
- engine/catalog.py +5 -0
- engine/policy.py +25 -3
- engine/predict_yearly.py +2 -1
- engine/report_model.py +24 -0
- engine/state_builder.py +3 -1
- engine/tests/test_pipeline.py +38 -1
- static/app.js +2 -0
- static/index.html +2 -2
analyst.py
CHANGED
|
@@ -63,7 +63,7 @@ SEAT_RE = re.compile(r"^\s+(?:\w+ )?\((Neutral|Bear|Bull|PM|fallback)\)\s+(?:[-+
|
|
| 63 |
# The framework's inputs for this company, shown while the storytellers work ("under the hood"). Only these
|
| 64 |
# fields cross, each type-checked and bounded; values, ratings and storyteller views never do.
|
| 65 |
FACT_FIELDS = {"industry": str, "country": str, "beta": float, "beta_basis": str, "erp": float, "erp_month": str,
|
| 66 |
-
"crp": float, "stage": str, "segments": list, "segment_build": bool, "consensus": bool}
|
| 67 |
|
| 68 |
|
| 69 |
def _facts(payload: str) -> dict:
|
|
|
|
| 63 |
# The framework's inputs for this company, shown while the storytellers work ("under the hood"). Only these
|
| 64 |
# fields cross, each type-checked and bounded; values, ratings and storyteller views never do.
|
| 65 |
FACT_FIELDS = {"industry": str, "country": str, "beta": float, "beta_basis": str, "erp": float, "erp_month": str,
|
| 66 |
+
"crp": float, "stage": str, "segments": list, "segment_build": bool, "consensus": bool, "ml_rank": str}
|
| 67 |
|
| 68 |
|
| 69 |
def _facts(payload: str) -> dict:
|
engine/brief.py
CHANGED
|
@@ -42,7 +42,9 @@ def _quant_block(s):
|
|
| 42 |
else f"You may describe it only as '{pm.get('allowed_claim_strength')}'.")]
|
| 43 |
lines.append(f"Cross-sectional panel rank: {pr.get('state')} ({pr.get('basis')}). "
|
| 44 |
+ ("Not usable as evidence: its figures are withheld." if not pr.get("persona_visibility")
|
| 45 |
-
else "Usable only as
|
|
|
|
|
|
|
| 46 |
return "\n".join(lines)
|
| 47 |
|
| 48 |
|
|
|
|
| 42 |
else f"You may describe it only as '{pm.get('allowed_claim_strength')}'.")]
|
| 43 |
lines.append(f"Cross-sectional panel rank: {pr.get('state')} ({pr.get('basis')}). "
|
| 44 |
+ ("Not usable as evidence: its figures are withheld." if not pr.get("persona_visibility")
|
| 45 |
+
else f"Usable only as {pr.get('allowed_claim_strength')}: a machine-learning model ranks the stock above "
|
| 46 |
+
"[[ML_RANK_SHARE]] of the [[ML_RANK_UNIVERSE]] largest US companies (decile [[ML_RANK_DECILE]]) for "
|
| 47 |
+
"12-month return relative to them. It is a ranking, not a price target."))
|
| 48 |
return "\n".join(lines)
|
| 49 |
|
| 50 |
|
engine/catalog.py
CHANGED
|
@@ -131,6 +131,11 @@ CLAIMS = [
|
|
| 131 |
("forecast", "consensus")),
|
| 132 |
ClaimDef("LIFECYCLE_FORWARD_GROWTH", "valuation.lifecycle.forward_revenue_growth", PCT_SIGNED, "forward revenue growth used for staging",
|
| 133 |
("trailing",)),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
ClaimDef("DMD_INDUSTRY", "valuation.damodaran_inputs.industry", TEXT, "industry (Damodaran classification)"),
|
| 135 |
ClaimDef("DMD_UNLEVERED_BETA", "valuation.damodaran_inputs.unlevered_beta", RATIO, "industry unlevered beta (cash-corrected)"),
|
| 136 |
ClaimDef("DMD_BETA", "valuation.damodaran_inputs.beta", RATIO, "bottom-up beta (re-levered at the company's D/E)"),
|
|
|
|
| 131 |
("forecast", "consensus")),
|
| 132 |
ClaimDef("LIFECYCLE_FORWARD_GROWTH", "valuation.lifecycle.forward_revenue_growth", PCT_SIGNED, "forward revenue growth used for staging",
|
| 133 |
("trailing",)),
|
| 134 |
+
ClaimDef("ML_RANK_SHARE", "quant.panel_rank.figures.rank_share", PCT, "share of ranked companies below this one"),
|
| 135 |
+
ClaimDef("ML_RANK_DECILE", "quant.panel_rank.figures.decile", COUNT, "machine-learning rank decile"),
|
| 136 |
+
ClaimDef("ML_RANK_UNIVERSE", "quant.panel_rank.figures.n_universe", COUNT, "companies ranked"),
|
| 137 |
+
ClaimDef("ML_RANK_IC", "quant.panel_rank.reliability.rank_ic", RATIO, "walk-forward rank correlation"),
|
| 138 |
+
ClaimDef("ML_RANK_POSITIVE", "quant.panel_rank.reliability.ic_positive_share", PCT, "share of test months the rank was right-ordered"),
|
| 139 |
ClaimDef("DMD_INDUSTRY", "valuation.damodaran_inputs.industry", TEXT, "industry (Damodaran classification)"),
|
| 140 |
ClaimDef("DMD_UNLEVERED_BETA", "valuation.damodaran_inputs.unlevered_beta", RATIO, "industry unlevered beta (cash-corrected)"),
|
| 141 |
ClaimDef("DMD_BETA", "valuation.damodaran_inputs.beta", RATIO, "bottom-up beta (re-levered at the company's D/E)"),
|
engine/policy.py
CHANGED
|
@@ -400,6 +400,28 @@ def panel_reliability(meta=None):
|
|
| 400 |
if ic is None or sharpe is None:
|
| 401 |
return reliability_record("UNAVAILABLE", basis="the panel snapshot carries no walk-forward validation metadata",
|
| 402 |
extra={"model": "cross-sectional panel-ML rank"})
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
if ic is None or sharpe is None:
|
| 401 |
return reliability_record("UNAVAILABLE", basis="the panel snapshot carries no walk-forward validation metadata",
|
| 402 |
extra={"model": "cross-sectional panel-ML rank"})
|
| 403 |
+
# 12-month windows of consecutive monthly cross-sections overlap: significance is judged on the
|
| 404 |
+
# non-overlapping count, and the latest test block must not have failed (today's regime is what a
|
| 405 |
+
# live analysis relies on; every approach went negative in 2024-25 in the prediction backtest)
|
| 406 |
+
t, n_ind = _f(m.get("ic_t")), _f(m.get("n_independent")) or n / 12.0
|
| 407 |
+
folds = m.get("folds") or []
|
| 408 |
+
recent = _f((folds[-1] if folds else {}).get("rank_ic"))
|
| 409 |
+
recent_ok = recent is not None and recent >= 0
|
| 410 |
+
if ic >= 0.10 and sharpe >= 0.8 and (t or 0) >= 2.0 and n_ind >= 8 and recent is not None and recent > 0:
|
| 411 |
+
state = "VALIDATED"
|
| 412 |
+
elif ic >= 0.02 and (t or 0) >= 1.0 and n_ind >= 5 and recent_ok:
|
| 413 |
+
state = "LIMITED"
|
| 414 |
+
else:
|
| 415 |
+
state = "UNRELIABLE"
|
| 416 |
+
why = ("" if state != "UNRELIABLE" else
|
| 417 |
+
"; the latest test block was negative" if (recent is not None and recent < 0) else
|
| 418 |
+
"; too weak or too few independent periods")
|
| 419 |
+
basis = (f"walk-forward rank correlation {ic:+.3f} (t {t if t is not None else float('nan'):.1f} over {n_ind:.1f} independent years), "
|
| 420 |
+
f"positive in {(_f(m.get('ic_positive_share')) or 0) * 100:.0f}% of months, decile Sharpe {sharpe:.2f}"
|
| 421 |
+
+ (f", latest test block {(folds[-1] or {}).get('test')} {recent:+.3f}" if recent is not None else "") + why)
|
| 422 |
+
return reliability_record(state, basis=basis,
|
| 423 |
+
extra={"model": "cross-sectional panel-ML rank", "rank_ic": ic, "decile_sharpe": sharpe, "n_periods": n,
|
| 424 |
+
"ic_t": t, "n_independent": n_ind, "recent_block_ic": recent,
|
| 425 |
+
"recent_block": (folds[-1] if folds else {}).get("test"),
|
| 426 |
+
"ic_positive_share": _f(m.get("ic_positive_share")),
|
| 427 |
+
"failed_recently": bool(recent is not None and recent < 0)})
|
engine/predict_yearly.py
CHANGED
|
@@ -7582,7 +7582,8 @@ Examples:
|
|
| 7582 |
"stage": _CRUCIBLE_STORY_STAGE,
|
| 7583 |
"segments": [g.get("name") for g in (_sbp.get("segments") or [])] if _sbp.get("filed") else [],
|
| 7584 |
"segment_build": bool(_sbp.get("segment_build_required")),
|
| 7585 |
-
"consensus": bool(_eec.get("current_year_revenue"))
|
|
|
|
| 7586 |
print("@@progress story-start " + json.dumps(_facts, default=str), flush=True)
|
| 7587 |
story_val_result = _sd.story_value(_ctx, _story_call, _story_call2, _story_call3, verbose=args.verbose)
|
| 7588 |
if not story_val_result:
|
|
|
|
| 7582 |
"stage": _CRUCIBLE_STORY_STAGE,
|
| 7583 |
"segments": [g.get("name") for g in (_sbp.get("segments") or [])] if _sbp.get("filed") else [],
|
| 7584 |
"segment_build": bool(_sbp.get("segment_build_required")),
|
| 7585 |
+
"consensus": bool(_eec.get("current_year_revenue")),
|
| 7586 |
+
"ml_rank": (_panel_state(ticker)[0] or {}).get("state")}
|
| 7587 |
print("@@progress story-start " + json.dumps(_facts, default=str), flush=True)
|
| 7588 |
story_val_result = _sd.story_value(_ctx, _story_call, _story_call2, _story_call3, verbose=args.verbose)
|
| 7589 |
if not story_val_result:
|
engine/report_model.py
CHANGED
|
@@ -412,6 +412,7 @@ def build_user_report(s, cat, prose, limitations):
|
|
| 412 |
exc = [x.get("name") for x in (s.blend.get("excluded_sources") or ()) if x.get("name")]
|
| 413 |
if exc:
|
| 414 |
B.append(_para("blend", r.t("Carrying no weight in this analysis: " + ", ".join(exc) + ".")))
|
|
|
|
| 415 |
B.append(_para("blend", r.t("The headline is the share-weighted sum of these views, a price return that excludes dividends: "),
|
| 416 |
r.c("HEADLINE_RETURN"), r.t(".")))
|
| 417 |
|
|
@@ -435,6 +436,29 @@ def build_user_report(s, cat, prose, limitations):
|
|
| 435 |
return UserReport(s.identity.ticker, s.identity.get("company_name"), s.identity.get("as_of"), s.version, B, claims, limitations)
|
| 436 |
|
| 437 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
# ═══════════════════════════════════════════════════════════════════════════════════════
|
| 439 |
# serialisation: both formats walk the same blocks
|
| 440 |
# ═══════════════════════════════════════════════════════════════════════════════════════
|
|
|
|
| 412 |
exc = [x.get("name") for x in (s.blend.get("excluded_sources") or ()) if x.get("name")]
|
| 413 |
if exc:
|
| 414 |
B.append(_para("blend", r.t("Carrying no weight in this analysis: " + ", ".join(exc) + ".")))
|
| 415 |
+
B += _ml_paragraphs(s, r, cat)
|
| 416 |
B.append(_para("blend", r.t("The headline is the share-weighted sum of these views, a price return that excludes dividends: "),
|
| 417 |
r.c("HEADLINE_RETURN"), r.t(".")))
|
| 418 |
|
|
|
|
| 436 |
return UserReport(s.identity.ticker, s.identity.get("company_name"), s.identity.get("as_of"), s.version, B, claims, limitations)
|
| 437 |
|
| 438 |
|
| 439 |
+
def _ml_paragraphs(s, r, cat):
|
| 440 |
+
"""Machine learning, in plain words. Two models are checked on every run; each counts only as far
|
| 441 |
+
as its out-of-sample record allows. A model that is not admitted is described without its figures."""
|
| 442 |
+
pm = (s.quant.get("price_model") or {}).get("reliability") or {}
|
| 443 |
+
pr = (s.quant.get("panel_rank") or {}).get("reliability") or {}
|
| 444 |
+
out = []
|
| 445 |
+
if pr.get("persona_visibility") and "ML_RANK_SHARE" in cat:
|
| 446 |
+
out.append(_para("blend", r.t("Machine learning: a model trained on twelve years of data ranks the stock above "), r.c("ML_RANK_SHARE"),
|
| 447 |
+
r.t(" of the "), r.c("ML_RANK_UNIVERSE"), r.t(" largest US companies (decile "), r.c("ML_RANK_DECILE"),
|
| 448 |
+
r.t(") for 12-month return relative to them. In walk-forward tests its rank correlation was "), r.c("ML_RANK_IC"),
|
| 449 |
+
r.t(", right-ordered in "), r.c("ML_RANK_POSITIVE"),
|
| 450 |
+
r.t(" of months. The analysts may cite it as weak evidence; it does not enter the headline return.")))
|
| 451 |
+
elif pr.get("state") == "UNRELIABLE":
|
| 452 |
+
why = ("it failed in the most recent test period" if pr.get("failed_recently") else "its out-of-sample record is too weak")
|
| 453 |
+
out.append(_para("blend", r.t("Machine learning: a model that ranks each stock against the largest US companies was checked "
|
| 454 |
+
f"and not used, because {why}.")))
|
| 455 |
+
if pm.get("state") in ("UNRELIABLE", "UNAVAILABLE"):
|
| 456 |
+
out.append(_para("blend", r.t("A single-stock machine-learning price model was also checked and not used: "
|
| 457 |
+
+ ("its out-of-sample direction calls were no better than chance."
|
| 458 |
+
if pm.get("state") == "UNRELIABLE" else "there was not enough history to test it."))))
|
| 459 |
+
return out
|
| 460 |
+
|
| 461 |
+
|
| 462 |
# ═══════════════════════════════════════════════════════════════════════════════════════
|
| 463 |
# serialisation: both formats walk the same blocks
|
| 464 |
# ═══════════════════════════════════════════════════════════════════════════════════════
|
engine/state_builder.py
CHANGED
|
@@ -401,8 +401,10 @@ def build_forecast_stage(state, *, pred_fund, fair_val, story, engine_run, engin
|
|
| 401 |
"rank_ic": _f((ml or {}).get("backtest_rank_ic")),
|
| 402 |
"mae": _f((ml or {}).get("backtest_mae")),
|
| 403 |
"n_oos_eff": _f((ml or {}).get("n_oos_effective"))}}
|
|
|
|
|
|
|
| 404 |
s.quant.panel_rank = {"reliability": dict(panel_rel or {}),
|
| 405 |
-
"figures":
|
| 406 |
"diagnostics_only": dict(panel_row) if panel_row else None}
|
| 407 |
|
| 408 |
# macro, technical, sentiment
|
|
|
|
| 401 |
"rank_ic": _f((ml or {}).get("backtest_rank_ic")),
|
| 402 |
"mae": _f((ml or {}).get("backtest_mae")),
|
| 403 |
"n_oos_eff": _f((ml or {}).get("n_oos_effective"))}}
|
| 404 |
+
_pfig = (dict(panel_row, rank_share=(_f(panel_row.get("xs_rank_pct")) or 0.0) / 100.0)
|
| 405 |
+
if (panel_row and (panel_rel or {}).get("persona_visibility")) else None)
|
| 406 |
s.quant.panel_rank = {"reliability": dict(panel_rel or {}),
|
| 407 |
+
"figures": _pfig,
|
| 408 |
"diagnostics_only": dict(panel_row) if panel_row else None}
|
| 409 |
|
| 410 |
# macro, technical, sentiment
|
engine/tests/test_pipeline.py
CHANGED
|
@@ -161,7 +161,44 @@ def test_persona_brief_carries_tokens_not_quant_figures():
|
|
| 161 |
|
| 162 |
def test_panel_reliability_requires_metadata():
|
| 163 |
assert pol.panel_reliability(None)["state"] == "UNAVAILABLE"
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
|
| 167 |
# ── storytellers ─────────────────────────────────────────────────────────────────────
|
|
|
|
| 161 |
|
| 162 |
def test_panel_reliability_requires_metadata():
|
| 163 |
assert pol.panel_reliability(None)["state"] == "UNAVAILABLE"
|
| 164 |
+
# a record without a significance test or fold history cannot be admitted
|
| 165 |
+
assert pol.panel_reliability({"rank_ic": 0.05, "decile_sharpe": 0.6, "n_periods": 30})["state"] == "UNRELIABLE"
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def test_panel_reliability_judges_the_walk_forward_record():
|
| 169 |
+
"""The shipped model's real record (2026-09-23 build): pooled rank IC +0.038, t 1.4 over 10.5 independent
|
| 170 |
+
years, but the latest block (2024-2025) was negative, so it is not admitted for a live analysis."""
|
| 171 |
+
real = {"rank_ic": 0.0383, "ic_t": 1.39, "ic_positive_share": 0.698, "decile_sharpe": 0.48, "n_periods": 126,
|
| 172 |
+
"n_independent": 10.5, "folds": [{"test": "2016-2017", "rank_ic": 0.1007}, {"test": "2022-2023", "rank_ic": 0.0393},
|
| 173 |
+
{"test": "2024-2025", "rank_ic": -0.0555}]}
|
| 174 |
+
r = pol.panel_reliability(real)
|
| 175 |
+
assert r["state"] == "UNRELIABLE" and r["failed_recently"] and "latest test block 2024-2025 -0.056" in r["basis"]
|
| 176 |
+
ok = dict(real, folds=real["folds"][:-1] + [{"test": "2024-2025", "rank_ic": 0.012}])
|
| 177 |
+
assert pol.panel_reliability(ok)["state"] == "LIMITED"
|
| 178 |
+
strong = dict(ok, rank_ic=0.12, ic_t=2.4, decile_sharpe=0.9)
|
| 179 |
+
assert pol.panel_reliability(strong)["state"] == "VALIDATED"
|
| 180 |
+
assert pol.panel_reliability(dict(ok, ic_t=0.7))["state"] == "UNRELIABLE" # not significant
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def test_machine_learning_is_described_in_plain_words(good):
|
| 184 |
+
"""Admitted: the rank and its record as audited claims. Not admitted: one sentence, no figures."""
|
| 185 |
+
import policy as pol2
|
| 186 |
+
d = F.all_data()
|
| 187 |
+
run, payload, failed = F.run_pipeline(data=d)
|
| 188 |
+
md = payload["markdown"]
|
| 189 |
+
assert failed is None and "single-stock machine-learning price model was also checked and not used" in md
|
| 190 |
+
rel = pol2.panel_reliability({"rank_ic": 0.05, "ic_t": 1.6, "ic_positive_share": 0.7, "decile_sharpe": 0.6, "n_periods": 120,
|
| 191 |
+
"n_independent": 10, "folds": [{"test": "2024-2025", "rank_ic": 0.02}]})
|
| 192 |
+
row = {"decile": 8, "xs_rank_pct": 74.0, "pred_excess_12m": 0.03, "as_of": "2026-09-18", "n_universe": 936}
|
| 193 |
+
run, payload, failed = F.run_pipeline(data=d, forecast_over={"panel_rel": rel, "panel_row": row})
|
| 194 |
+
md = payload["markdown"]
|
| 195 |
+
assert failed is None, failed
|
| 196 |
+
assert "ranks the stock above 74.0% of the 936 largest US companies (decile 8)" in md and "rank correlation was 0.05" in md
|
| 197 |
+
bad = pol2.panel_reliability({"rank_ic": 0.04, "ic_t": 1.4, "decile_sharpe": 0.5, "n_periods": 126, "n_independent": 10.5,
|
| 198 |
+
"folds": [{"test": "2024-2025", "rank_ic": -0.05}]})
|
| 199 |
+
run, payload, failed = F.run_pipeline(data=d, forecast_over={"panel_rel": bad, "panel_row": row})
|
| 200 |
+
assert failed is None and "checked and not used, because it failed in the most recent test period" in payload["markdown"]
|
| 201 |
+
assert "74.0%" not in payload["markdown"] # an excluded model's figures never appear
|
| 202 |
|
| 203 |
|
| 204 |
# ── storytellers ─────────────────────────────────────────────────────────────────────
|
static/app.js
CHANGED
|
@@ -74,6 +74,8 @@ function valuationHood(f) {
|
|
| 74 |
? `${f.segments.join(", ")} have different economics, so each storyteller must value them one by one.`
|
| 75 |
: `${f.segments.join(", ")} are valued together; their economics are close enough.`]);
|
| 76 |
if (f.consensus) it.push(["Near term", "The first two years lean on analysts' revenue estimates; after that, growth fades toward the economy's."]);
|
|
|
|
|
|
|
| 77 |
it.push(["Storytellers", "Three different AI models are each writing a story at the same time: market size, share, margins and reinvestment."]);
|
| 78 |
it.push(["Cash flows", "Each story becomes ten years of cash flow, discounted at today's rate for five years and then at a rate drifting toward a mature company's."]);
|
| 79 |
it.push(["The value", "The middle of the three values is adopted, so no single storyteller sets it."]);
|
|
|
|
| 74 |
? `${f.segments.join(", ")} have different economics, so each storyteller must value them one by one.`
|
| 75 |
: `${f.segments.join(", ")} are valued together; their economics are close enough.`]);
|
| 76 |
if (f.consensus) it.push(["Near term", "The first two years lean on analysts' revenue estimates; after that, growth fades toward the economy's."]);
|
| 77 |
+
if (f.ml_rank === "LIMITED" || f.ml_rank === "VALIDATED") it.push(["Machine learning", "A model trained on twelve years of data ranks the stock against the largest US companies. Its test record makes it supporting evidence for the analysts; it does not set the rating."]);
|
| 78 |
+
else if (f.ml_rank === "UNRELIABLE") it.push(["Machine learning", "A model that ranks the stock against the largest US companies was checked and switched off for now: its recent test record is too weak to rely on."]);
|
| 79 |
it.push(["Storytellers", "Three different AI models are each writing a story at the same time: market size, share, margins and reinvestment."]);
|
| 80 |
it.push(["Cash flows", "Each story becomes ten years of cash flow, discounted at today's rate for five years and then at a rate drifting toward a mature company's."]);
|
| 81 |
it.push(["The value", "The middle of the three values is adopted, so no single storyteller sets it."]);
|
static/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
| 6 |
<meta name="theme-color" content="#0c0a0d">
|
| 7 |
<title>Crucible</title>
|
| 8 |
-
<link rel="stylesheet" href="/static/app.css?v=
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<header>
|
|
@@ -89,6 +89,6 @@
|
|
| 89 |
<span class="mini-fill" id="mini-fill"></span><span class="mini-text" id="mini-text">Starting…</span>
|
| 90 |
</button>
|
| 91 |
|
| 92 |
-
<script src="/static/app.js?v=
|
| 93 |
</body>
|
| 94 |
</html>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
| 6 |
<meta name="theme-color" content="#0c0a0d">
|
| 7 |
<title>Crucible</title>
|
| 8 |
+
<link rel="stylesheet" href="/static/app.css?v=20260923y">
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<header>
|
|
|
|
| 89 |
<span class="mini-fill" id="mini-fill"></span><span class="mini-text" id="mini-text">Starting…</span>
|
| 90 |
</button>
|
| 91 |
|
| 92 |
+
<script src="/static/app.js?v=20260923y"></script>
|
| 93 |
</body>
|
| 94 |
</html>
|