chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:29:17 +08:00
commit 50bbe68809
112 changed files with 16932 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
diff_pct.py — char-level diff_pct on normalized text.
Replaces the legacy line-level diff in cheat-shoot Phase 3b, which
inflated diff_pct for spoken-style transcripts (long markdown lines vs
short transcribed lines) and falsely triggered v2 re-predictions.
Algorithm:
1. Normalize both files (strip markdown elements, collapse whitespace)
2. Char-level Levenshtein distance / max(len_a, len_b)
3. Output integer 0-100
Backend selection:
- Prefer rapidfuzz (C-backed, ~ms for 10KB strings) — `pip install rapidfuzz`
- Fallback to stdlib difflib.SequenceMatcher (slower but always available)
Usage:
python3 tools/diff_pct.py <orig_path> <new_path>
Output:
Single integer 0-100 on stdout.
Exit codes:
0 = success (number on stdout)
2 = bad args
3 = file read error
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# Markdown / formatting noise that whisper-style transcripts don't have.
# Stripping these from BOTH sides equalizes the surface so we measure
# content-similarity, not formatting-similarity.
_MARKDOWN_HEADER = re.compile(r"^#+\s+.*$", re.MULTILINE)
_MARKDOWN_HR = re.compile(r"^[-=*]{3,}\s*$", re.MULTILINE)
_MARKDOWN_BLOCKQUOTE = re.compile(r"^>+\s*", re.MULTILINE)
_MARKDOWN_LIST = re.compile(r"^[-*+]\s+|^\d+\.\s+", re.MULTILINE)
_FORMATTING_PUNCT = re.compile(r"[「」『』\"`*_~]")
_ALL_WHITESPACE = re.compile(r"\s+")
def normalize(text: str) -> str:
"""Strip markdown / formatting noise, collapse to single 'sentence'."""
text = _MARKDOWN_HEADER.sub("", text)
text = _MARKDOWN_HR.sub("", text)
text = _MARKDOWN_BLOCKQUOTE.sub("", text)
text = _MARKDOWN_LIST.sub("", text)
text = _FORMATTING_PUNCT.sub("", text)
text = _ALL_WHITESPACE.sub("", text)
return text
def diff_pct(a: str, b: str) -> tuple[int, str]:
"""Return (0-100 int, backend_name)."""
a_n = normalize(a)
b_n = normalize(b)
max_len = max(len(a_n), len(b_n))
if max_len == 0:
return 0, "trivial"
# Try rapidfuzz first (faster + matches the spec)
try:
from rapidfuzz.distance import Levenshtein
d = Levenshtein.distance(a_n, b_n)
return (d * 100) // max_len, "rapidfuzz"
except ImportError:
pass
# Fallback to stdlib difflib SequenceMatcher.
# ratio() returns 0-1 similarity; we want 0-100 distance.
# SequenceMatcher's algorithm differs from pure Levenshtein but
# for our use case (semantic content similarity) it's well-correlated.
from difflib import SequenceMatcher
sm = SequenceMatcher(a=a_n, b=b_n, autojunk=False)
sim = sm.ratio()
return int(round((1 - sim) * 100)), "difflib"
def main() -> int:
if len(sys.argv) != 3:
print("usage: diff_pct.py <orig_path> <new_path>", file=sys.stderr)
return 2
try:
a = Path(sys.argv[1]).read_text(encoding="utf-8")
b = Path(sys.argv[2]).read_text(encoding="utf-8")
except OSError as e:
print(f"read error: {e}", file=sys.stderr)
return 3
pct, backend = diff_pct(a, b)
print(pct)
# backend goes to stderr so callers can inspect without parsing stdout
print(f"backend={backend} a_norm_len={len(normalize(a))} b_norm_len={len(normalize(b))}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
#
# diff_pct_test.sh — regression test for tools/diff_pct.py
#
# Validates 3 fixture cases that the legacy line-level diff failed:
# case 1: long markdown lines vs spoken-transcript short lines, same content
# → expected diff_pct < 30 (was ~198% under legacy)
# case 2: completely different topic
# → expected diff_pct ≥ 60
# case 3: orig + ~20% new content appended
# → expected diff_pct 10-30
#
# Usage:
# bash tools/diff_pct_test.sh
# Exit:
# 0 = all pass
# 1 = ≥1 failure
#
# Runs against whichever backend is installed (rapidfuzz preferred, difflib
# fallback). Both should pass these ranges — they're chosen to be wide
# enough to absorb backend-algorithm differences.
set -euo pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
DIFF_PCT="$SCRIPT_DIR/diff_pct.py"
if [[ ! -f "$DIFF_PCT" ]]; then
echo "❌ diff_pct.py not found at $DIFF_PCT" >&2
exit 1
fi
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
PASS=0
FAIL=0
run_case() {
local label="$1"
local orig="$2"
local new="$3"
local min="$4"
local max="$5"
local stderr_out
stderr_out=$(mktemp)
local actual
actual=$(python3 "$DIFF_PCT" "$orig" "$new" 2>"$stderr_out")
local backend
backend=$(grep -oE 'backend=[a-z]+' "$stderr_out" | head -1 || echo "backend=?")
rm -f "$stderr_out"
if (( actual >= min && actual <= max )); then
echo "$label: diff_pct=$actual ∈ [$min, $max] ($backend)"
PASS=$((PASS+1))
else
echo "$label: diff_pct=$actual NOT in [$min, $max] ($backend)"
FAIL=$((FAIL+1))
fi
}
echo ""
echo "=== Case 1: long markdown line vs spoken-transcript short lines, same content ==="
cat > "$TMP/case1_orig.md" <<'EOF'
# 视频草稿 — 评审会复盘
「最近发现一个现象」——所有审稿人都在说一样的话:你的研究太老套了。
但你仔细看,他们引用的全是 5 年前的反应。AI 不是新东西。新的是这次大家集体觉醒了。
**升维点**:当所有人都在追新概念的时候,先看穿规律的人已经在用工具赚钱了。
EOF
cat > "$TMP/case1_shot.md" <<'EOF'
最近发现一个现象
所有审稿人都在说一样的话
你的研究太老套了
但你仔细看
他们引用的全是
5 年前的反应
AI 不是新东西
新的是这次
大家集体觉醒了
当所有人都在追新概念的时候
先看穿规律的人
已经在用工具赚钱了
EOF
run_case "spoken-style line breaks, content preserved" \
"$TMP/case1_orig.md" "$TMP/case1_shot.md" 0 30
echo ""
echo "=== Case 2: completely different topic ==="
cat > "$TMP/case2_orig.md" <<'EOF'
# AI 焦虑
最近 AI 大模型发布得太快了,每周一个新工具。
你刚学会一个,下周它就被淘汰了。
这种焦虑本质上是工具焦虑,不是能力焦虑。
真正不变的是你解决问题的范式——选好题、定好评估、跑通闭环。
EOF
cat > "$TMP/case2_shot.md" <<'EOF'
今天聊聊我家的猫
它叫橘子 是一只英短
最喜欢窝在窗台上晒太阳
我每天下班回家
看到它就忘了所有烦恼
它今天还偷吃了我的牛奶
被我发现了 装作没事
猫真是世界上最神奇的生物
EOF
run_case "completely different topic" \
"$TMP/case2_orig.md" "$TMP/case2_shot.md" 60 100
echo ""
echo "=== Case 3: orig + ~20% appended (outro / CTA scenario) ==="
# Realistic 创作者 scenario: 拍前稿 ~150 字, 拍时加一句 30 字 outro
# 增量 / 原稿 ≈ 20%, Levenshtein/max(orig,new) ≈ 16-20%
cat > "$TMP/case3_orig.md" <<'EOF'
# 视频 — 关于宿命论
我以前不信宿命论。直到我跑了这个工具——它让我拍了一条视频,预测了流量。
我想证明它是错的,告诉了观众希望集体观测让数据偏移。结果数据是准的。
我没逃出宿命论。我只是从一阶跳到二阶——AI 在观测观测者。
EOF
cat > "$TMP/case3_shot.md" <<'EOF'
我以前不信宿命论。直到我跑了这个工具——它让我拍了一条视频,预测了流量。
我想证明它是错的,告诉了观众希望集体观测让数据偏移。结果数据是准的。
我没逃出宿命论。我只是从一阶跳到二阶——AI 在观测观测者。
此时此刻看到这条的你——是出于好奇,还是在完成算法的最后一次落位?
EOF
run_case "~20% appended outro" \
"$TMP/case3_orig.md" "$TMP/case3_shot.md" 10 30
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
if [[ $FAIL -eq 0 ]]; then
exit 0
else
exit 1
fi
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Generate a static Star History chart (docs/star-history.svg) in the
authentic star-history.com hand-drawn (xkcd) style.
Why static: GitHub now restricts stargazer `starred_at` timestamps to a
repo's own admins/collaborators, so star-history.com's anonymous <img>
embed no longer renders. This reads the data with your own authenticated
`gh` token (works because you're an admin) and writes a self-contained SVG
that matches star-history's look — embedded xkcd font, wobbly hand-drawn
axes/line, #dd4528 series color, legend + watermark.
Usage:
gh auth status
python3 tools/gen-star-history.py # -> docs/star-history.svg
Jitter is seeded, so identical star data always yields an identical SVG
(no spurious diffs when the scheduled workflow re-runs).
"""
import datetime as dt
import math
import os
import random
import subprocess
import sys
REPO = "XBuilderLAB/cheat-on-content"
HERE = os.path.dirname(__file__)
OUT = os.path.join(HERE, "..", "docs", "star-history.svg")
FONT_CSS = os.path.join(HERE, "xkcd-font.css")
LINE = "#dd4528" # star-history single-series color
INK = "#000000" # axes / labels
MUTE = "#666666" # watermark
random.seed(20260707) # deterministic wobble
def fetch_timestamps(repo):
out = subprocess.run(
["gh", "api", "--paginate",
"-H", "Accept: application/vnd.github.star+json",
f"/repos/{repo}/stargazers?per_page=100",
"-q", ".[].starred_at"],
capture_output=True, text=True,
)
if out.returncode != 0:
sys.exit(f"gh api failed:\n{out.stderr}")
ts = [dt.datetime.strptime(l.strip(), "%Y-%m-%dT%H:%M:%SZ")
for l in out.stdout.splitlines() if l.strip()]
ts.sort()
return ts
def nice_top(v):
for s in (1000, 2000, 2500, 5000, 10000, 20000):
if v / s <= 6:
return math.ceil(v / s) * s, s
return math.ceil(v / 50000) * 50000, 50000
def sketch(x1, y1, x2, y2, wob=1.6, seg=None):
"""Hand-drawn line: a path with jittered intermediate points."""
dist = math.hypot(x2 - x1, y2 - y1)
seg = seg or max(2, int(dist / 26))
nx, ny = -(y2 - y1) / (dist or 1), (x2 - x1) / (dist or 1) # normal
pts = []
for i in range(seg + 1):
t = i / seg
j = 0 if i in (0, seg) else random.uniform(-wob, wob)
pts.append((x1 + (x2 - x1) * t + nx * j, y1 + (y2 - y1) * t + ny * j))
d = "M" + " L".join(f"{x:.1f},{y:.1f}" for x, y in pts)
return d
def build_svg(ts, repo):
n = len(ts)
t0, t1 = ts[0], ts[-1]
span = (t1 - t0).total_seconds() or 1
W, H = 800, 520
L, R, T, B = 96, 34, 76, 78
pw, ph = W - L - R, H - T - B
ymax, ystep = nice_top(n)
def X(t):
return L + (t - t0).total_seconds() / span * pw
def Y(v):
return T + ph - (v / ymax) * ph
# cumulative, sampled for a clean hand-drawn stroke
step = max(1, n // 90)
raw = [(ts[i], i + 1) for i in range(0, n, step)] + [(t1, n)]
font = open(FONT_CSS).read().replace("&quot;", '"')
XK = "font-family:'xkcd','Comic Sans MS',cursive"
s = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" width="{W}" height="{H}" role="img" aria-label="Star History - {repo}">',
f'<defs><style>{font}</style></defs>',
f'<rect width="{W}" height="{H}" fill="#ffffff"/>']
# title
s.append(f'<text x="{W/2}" y="40" text-anchor="middle" style="{XK};font-size:26px" fill="{INK}">Star History</text>')
# y gridlines + labels
v = 0
while v <= ymax + 1:
y = Y(v)
s.append(f'<line x1="{L}" y1="{y:.1f}" x2="{L+pw}" y2="{y:.1f}" stroke="#e4e4e4" stroke-width="1"/>')
lab = ("0" if v == 0 else (f"{v//1000}K" if v >= 1000 else str(v)))
s.append(f'<text x="{L-14}" y="{y+6:.1f}" text-anchor="end" style="{XK};font-size:16px" fill="{INK}">{lab}</text>')
v += ystep
# x month ticks
y0, m0 = t0.year, t0.month
cur = dt.datetime(y0, m0, 1)
if cur < t0:
cur = dt.datetime(y0 + (m0 == 12), (m0 % 12) + 1, 1)
months = []
while cur <= t1:
months.append(cur)
cur = dt.datetime(cur.year + (cur.month == 12), (cur.month % 12) + 1, 1)
for t in months:
x = X(t)
s.append(f'<text x="{x:.1f}" y="{T+ph+26:.1f}" text-anchor="middle" style="{XK};font-size:16px" fill="{INK}">{t.strftime("%b")}</text>')
# hand-drawn axes
s.append(f'<path d="{sketch(L, T+ph, L+pw, T+ph, 1.4)}" fill="none" stroke="{INK}" stroke-width="2" stroke-linecap="round"/>')
s.append(f'<path d="{sketch(L, T+ph, L, T, 1.4)}" fill="none" stroke="{INK}" stroke-width="2" stroke-linecap="round"/>')
# axis titles
s.append(f'<text x="{L+pw/2:.1f}" y="{H-24}" text-anchor="middle" style="{XK};font-size:17px" fill="{INK}">Date</text>')
s.append(f'<text x="26" y="{T+ph/2:.1f}" text-anchor="middle" transform="rotate(-90 26 {T+ph/2:.1f})" style="{XK};font-size:17px" fill="{INK}">GitHub Stars</text>')
# the wobbly series line (chain of sketched segments)
xy = [(X(t), Y(v)) for t, v in raw]
d = "M{:.1f},{:.1f}".format(*xy[0])
for (x1, y1), (x2, y2) in zip(xy, xy[1:]):
seg = sketch(x1, y1, x2, y2, 1.1)
d += " L" + seg.split("M", 1)[1].replace(" L", " L")
s.append(f'<path d="{d}" fill="none" stroke="{LINE}" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"/>')
# end marker
ex, ey = xy[-1]
s.append(f'<circle cx="{ex:.1f}" cy="{ey:.1f}" r="5" fill="{LINE}"/>')
# legend (color stroke sample + repo, upper-left inside plot)
lx, ly = L + 24, T + 26
s.append(f'<path d="{sketch(lx, ly, lx+34, ly, 1.0)}" fill="none" stroke="{LINE}" stroke-width="3" stroke-linecap="round"/>')
s.append(f'<circle cx="{lx+17}" cy="{ly}" r="4" fill="{LINE}"/>')
s.append(f'<text x="{lx+44}" y="{ly+6}" style="{XK};font-size:16px" fill="{INK}">{repo}</text>')
# watermark
s.append(f'<text x="{L+pw}" y="{T-16}" text-anchor="end" style="{XK};font-size:15px" fill="{MUTE}">star-history.com</text>')
s.append('</svg>')
return "\n".join(s)
def main():
ts = fetch_timestamps(REPO)
with open(OUT, "w") as f:
f.write(build_svg(ts, REPO) + "\n")
print(f"wrote {os.path.normpath(OUT)} ({len(ts):,} stars, {ts[0].date()}{ts[-1].date()})")
if __name__ == "__main__":
main()
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""
score-curve.py — predict accuracy convergence chart for cheat-on-content.
Reads predictions/*.md (in the user's project), pairs each prediction's
center-of-bucket estimate against actual plays from the retrospective section,
and plots rolling-mean prediction error over time. The chart shows whether the
rubric is calibrating (error narrows) or drifting (error widens).
Usage:
python tools/score-curve.py [--predictions DIR] [--out PATH] [--window N]
Defaults:
--predictions ./predictions
--out score-curve.png
--window 5 (rolling-mean window in samples)
Dependencies: stdlib only for parsing; matplotlib for plotting (optional —
if absent, prints a CSV table to stdout instead).
"""
from __future__ import annotations
import argparse
import csv
import re
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional
# Bucket center mapping (the "中枢" if the prediction file doesn't spell it out
# explicitly). Units: 万 (10,000 plays). Adjust per platform if needed.
BUCKET_CENTERS = {
"<5w": 2.5,
"5-30w": 17.5,
"30-100w": 65.0,
"100-150w": 125.0,
">150w": 200.0,
}
PREDICTION_HEADER_RE = re.compile(r"^\*\*Bucket\*\*:\s*`?([^`\n]+?)`?\s*$", re.MULTILINE)
CENTER_RE = re.compile(r"中枢\s*[~约]?\s*(\d+(?:\.\d+)?)\s*w", re.IGNORECASE)
ACTUAL_PLAYS_RE = re.compile(r"播放[:]\s*\*?\*?(\d+(?:\.\d+)?)\s*w", re.IGNORECASE)
DATE_FROM_FILENAME_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})_")
@dataclass
class Sample:
file: Path
date: datetime
bucket: Optional[str]
predicted_center_w: Optional[float]
actual_plays_w: Optional[float]
@property
def has_retro(self) -> bool:
return self.actual_plays_w is not None
@property
def signed_error_pct(self) -> Optional[float]:
"""(actual - predicted) / predicted, in percent."""
if self.predicted_center_w is None or self.actual_plays_w is None or self.predicted_center_w == 0:
return None
return (self.actual_plays_w - self.predicted_center_w) / self.predicted_center_w * 100
@property
def abs_error_pct(self) -> Optional[float]:
sep = self.signed_error_pct
return abs(sep) if sep is not None else None
def parse_prediction_file(path: Path) -> Sample:
text = path.read_text(encoding="utf-8")
# Date from filename (YYYY-MM-DD_<id>_<short>.md)
m = DATE_FROM_FILENAME_RE.search(path.name)
if not m:
raise ValueError(f"{path.name}: filename does not start with YYYY-MM-DD_")
date = datetime.strptime(m.group(1), "%Y-%m-%d")
# Split prediction vs retro section
pred_section, _, retro_section = text.partition("## 复盘")
# Bucket from prediction section
bm = PREDICTION_HEADER_RE.search(pred_section)
bucket = bm.group(1).strip() if bm else None
# Predicted center: prefer explicit "中枢 ~50w", fall back to bucket midpoint
cm = CENTER_RE.search(pred_section)
if cm:
predicted_center_w = float(cm.group(1))
elif bucket and bucket in BUCKET_CENTERS:
predicted_center_w = BUCKET_CENTERS[bucket]
else:
predicted_center_w = None
# Actual plays from retro section
actual_plays_w = None
if retro_section.strip():
am = ACTUAL_PLAYS_RE.search(retro_section)
if am:
actual_plays_w = float(am.group(1))
return Sample(
file=path,
date=date,
bucket=bucket,
predicted_center_w=predicted_center_w,
actual_plays_w=actual_plays_w,
)
def collect_samples(predictions_dir: Path) -> list[Sample]:
samples: list[Sample] = []
for path in sorted(predictions_dir.glob("*.md")):
try:
samples.append(parse_prediction_file(path))
except (ValueError, OSError) as e:
print(f"warn: skipping {path.name}: {e}", file=sys.stderr)
return samples
def rolling_mean(values: list[float], window: int) -> list[float]:
if not values:
return []
out = []
for i in range(len(values)):
lo = max(0, i - window + 1)
chunk = values[lo : i + 1]
out.append(sum(chunk) / len(chunk))
return out
def render_chart(samples: list[Sample], out_path: Path, window: int) -> bool:
"""Returns True on success, False if matplotlib is unavailable."""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib import font_manager
except ImportError:
return False
# Try to find a CJK-capable font so Chinese labels render. Falls back silently
# to default if none available — labels will show as boxes but the chart still works.
for cand in ("PingFang SC", "Heiti SC", "Hiragino Sans GB", "Noto Sans CJK SC", "Microsoft YaHei", "WenQuanYi Zen Hei"):
try:
if any(cand.lower() in f.name.lower() for f in font_manager.fontManager.ttflist):
plt.rcParams["font.sans-serif"] = [cand] + plt.rcParams.get("font.sans-serif", [])
plt.rcParams["axes.unicode_minus"] = False
break
except Exception:
pass
samples_with_retro = [s for s in samples if s.has_retro and s.abs_error_pct is not None]
if not samples_with_retro:
print("error: no samples with retro data — nothing to plot", file=sys.stderr)
return True # signal "we did our part"; nothing to plot is not a missing-deps issue
samples_with_retro.sort(key=lambda s: s.date)
abs_errors = [s.abs_error_pct for s in samples_with_retro]
signed_errors = [s.signed_error_pct for s in samples_with_retro]
rolling = rolling_mean(abs_errors, window)
indices = list(range(1, len(samples_with_retro) + 1))
fig, ax = plt.subplots(figsize=(10, 5))
ax.bar(indices, abs_errors, alpha=0.3, label=f"|误差%| 单篇", color="steelblue")
ax.plot(indices, rolling, marker="o", linewidth=2, label=f"|误差%| 滚动 {window} 篇均值", color="firebrick")
ax.axhline(50, linestyle="--", linewidth=1, color="gray", label="cold-start 期参考线 (±50%)")
ax.axhline(25, linestyle=":", linewidth=1, color="green", label="校准成熟期目标 (±25%)")
ax.set_xlabel("第 N 篇校准样本")
ax.set_ylabel("|预测中枢偏差%|")
ax.set_title("Cheat-on-Content — 预测精度收敛曲线")
ax.set_xticks(indices)
ax.set_xticklabels([s.date.strftime("%m-%d") for s in samples_with_retro], rotation=45, ha="right")
ax.legend(loc="upper right")
ax.grid(True, alpha=0.3)
fig.tight_layout()
fig.savefig(out_path, dpi=150)
plt.close(fig)
return True
def render_csv(samples: list[Sample]) -> None:
"""Fallback when matplotlib is unavailable."""
writer = csv.writer(sys.stdout)
writer.writerow(["file", "date", "bucket", "predicted_center_w", "actual_plays_w", "signed_error_pct"])
for s in sorted(samples, key=lambda x: x.date):
writer.writerow(
[
s.file.name,
s.date.date().isoformat(),
s.bucket or "",
s.predicted_center_w if s.predicted_center_w is not None else "",
s.actual_plays_w if s.actual_plays_w is not None else "",
f"{s.signed_error_pct:.1f}" if s.signed_error_pct is not None else "",
]
)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--predictions", type=Path, default=Path("predictions"), help="prediction files directory")
ap.add_argument("--out", type=Path, default=Path("score-curve.png"), help="output chart path")
ap.add_argument("--window", type=int, default=5, help="rolling-mean window in samples")
args = ap.parse_args()
if not args.predictions.is_dir():
print(f"error: {args.predictions} is not a directory", file=sys.stderr)
return 2
samples = collect_samples(args.predictions)
if not samples:
print(f"error: no prediction files found under {args.predictions}", file=sys.stderr)
return 1
n_with_retro = sum(1 for s in samples if s.has_retro)
print(f"found {len(samples)} predictions, {n_with_retro} with retrospective data", file=sys.stderr)
plotted = render_chart(samples, args.out, args.window)
if plotted:
print(f"chart written → {args.out}", file=sys.stderr)
else:
print("matplotlib not installed — emitting CSV to stdout instead", file=sys.stderr)
render_csv(samples)
return 0
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because one or more lines are too long