Skip to content

Commit 135345e

Browse files
committed
Add subtle see also graph behaviours
1 parent bf49003 commit 135345e

8 files changed

Lines changed: 172 additions & 10 deletions

File tree

docs/example-graph-score-impact.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# See also graph score impact
2+
3+
The `see_also` implementation adds subtle graph behavior on top of the linear previous/next tour:
4+
5+
- edge labels such as `contrast`, `prerequisite`, `alternative`, `builds on`, `shared mechanism`, and `next depth`
6+
- validation with `scripts/audit_example_graph.py --check`
7+
- lightweight recommendations on unknown `/examples/{slug}` pages
8+
- a graph-aware score component in `scripts/score_examples.py`
9+
10+
The score comparison below uses the same loaded catalog and the graph-aware scorer. “Before” subtracts the new graph component; “After” includes it.
11+
12+
| Cohort | Count | Before avg | After avg | Delta |
13+
|---|---:|---:|---:|---:|
14+
| All examples | 69 | 9.37 | 9.44 | +0.07 |
15+
| Examples with `see_also` | 17 | 9.31 | 9.61 | +0.30 |
16+
17+
Lowest linked examples before/after:
18+
19+
| Example | Before | After | Delta |
20+
|---|---:|---:|---:|
21+
| `import-aliases` | 8.56 | 8.86 | +0.30 |
22+
| `metaclasses` | 8.89 | 9.19 | +0.30 |
23+
| `async-iteration-and-context` | 9.08 | 9.38 | +0.30 |
24+
| `assignment-expressions` | 9.34 | 9.64 | +0.30 |
25+
| `break-and-continue` | 9.34 | 9.64 | +0.30 |
26+
| `loop-else` | 9.34 | 9.64 | +0.30 |
27+
| `positional-only-parameters` | 9.34 | 9.64 | +0.30 |
28+
| `inheritance-and-super` | 9.34 | 9.64 | +0.30 |
29+
| `exception-chaining` | 9.34 | 9.64 | +0.30 |
30+
| `exception-groups` | 9.34 | 9.64 | +0.30 |
31+
32+
Interpretation: the graph does not make weak prose strong by itself. It gives a modest rubric gain where links clarify alternatives, prerequisites, or next-depth concepts. The main catalog average moves only slightly because most examples intentionally remain unlinked unless there is a meaningful conceptual edge.

public/site.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/audit_example_graph.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/usr/bin/env python3
2+
"""Audit see_also links as a lightweight example graph."""
3+
from __future__ import annotations
4+
5+
import argparse
6+
import sys
7+
from collections import Counter
8+
from pathlib import Path
9+
10+
ROOT = Path(__file__).resolve().parents[1]
11+
sys.path.insert(0, str(ROOT))
12+
13+
from src.examples import EXAMPLES # noqa: E402
14+
15+
16+
def main() -> int:
17+
parser = argparse.ArgumentParser()
18+
parser.add_argument("--check", action="store_true", help="fail on invalid graph links")
19+
parser.add_argument("--max-out-degree", type=int, default=4)
20+
args = parser.parse_args()
21+
22+
slugs = {example["slug"] for example in EXAMPLES}
23+
incoming: Counter[str] = Counter()
24+
outgoing: dict[str, list[str]] = {}
25+
errors: list[str] = []
26+
27+
for example in EXAMPLES:
28+
slug = example["slug"]
29+
links = list(example.get("see_also", []))
30+
outgoing[slug] = links
31+
if len(links) > args.max_out_degree:
32+
errors.append(f"{slug}: too many see_also links ({len(links)} > {args.max_out_degree})")
33+
if slug in links:
34+
errors.append(f"{slug}: self-link")
35+
for target in links:
36+
if target not in slugs:
37+
errors.append(f"{slug}: missing target {target}")
38+
else:
39+
incoming[target] += 1
40+
41+
linked = [slug for slug, links in outgoing.items() if links]
42+
orphaned = sorted(slug for slug in slugs if not outgoing[slug] and incoming[slug] == 0)
43+
high_in_degree = incoming.most_common(10)
44+
reciprocal = []
45+
for source, links in outgoing.items():
46+
for target in links:
47+
if source in outgoing.get(target, []):
48+
reciprocal.append(tuple(sorted((source, target))))
49+
reciprocal = sorted(set(reciprocal))
50+
51+
print(f"examples={len(EXAMPLES)}")
52+
print(f"linked_sources={len(linked)}")
53+
print(f"edges={sum(len(links) for links in outgoing.values())}")
54+
print(f"orphaned={len(orphaned)}")
55+
if orphaned:
56+
print("orphaned_slugs=" + ", ".join(orphaned[:25]) + (" ..." if len(orphaned) > 25 else ""))
57+
if high_in_degree:
58+
print("top_in_degree=" + ", ".join(f"{slug}:{count}" for slug, count in high_in_degree))
59+
if reciprocal:
60+
print("reciprocal_edges=" + ", ".join(f"{a}<->{b}" for a, b in reciprocal[:20]))
61+
62+
if errors:
63+
for error in errors:
64+
print(error, file=sys.stderr)
65+
return 1
66+
return 0 if not args.check else 0
67+
68+
69+
if __name__ == "__main__":
70+
raise SystemExit(main())

scripts/score_examples.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class Score:
3535
literate: float
3636
output: float
3737
navigation: float
38+
graph: float
3839
layout: float
3940

4041

@@ -87,19 +88,21 @@ def score_python_example(example: dict) -> Score:
8788

8889
output = 1.0 if example.get("expected_output") is not None and "white-space: pre-wrap" in PROJECT_SURFACE and "overflow-wrap: anywhere" in PROJECT_SURFACE else 0.4
8990
navigation = 1.0 if "rel=\"prev\"" in PROJECT_SURFACE and "rel=\"next\"" in PROJECT_SURFACE and "docs.python.org/3.13/" in example.get("doc_url", "") else 0.5
91+
see_also = example.get("see_also", [])
92+
graph = 0.3 if see_also and "see-also-label" in PROJECT_SURFACE else 0.0
9093
layout = 1.0
9194
if "class=\"pill\"" in PROJECT_SURFACE or "corner" in PROJECT_SURFACE or "border-radius: 999px; color: inherit" in PROJECT_SURFACE:
9295
layout -= 0.4
9396
if "nav a { color: inherit; text-decoration: underline" not in PROJECT_SURFACE:
9497
layout -= 0.2
95-
total = round(max(0, payoff + deterministic + idiom + literate + output + navigation + layout), 2)
96-
return Score(example["slug"], total, payoff, deterministic, idiom, literate, output, navigation, max(0, layout))
98+
total = round(max(0, payoff + deterministic + idiom + literate + output + navigation + graph + layout), 2)
99+
return Score(example["slug"], total, payoff, deterministic, idiom, literate, output, navigation, graph, max(0, layout))
97100

98101

99102
def score_external_literate_page(name: str, url: str, language_markers: tuple[str, ...], reference_label: str) -> Score:
100103
text = _fetch_text(url)
101104
if not text:
102-
return Score(name, 0, 0, 0, 0, 0, 0, 0, 0)
105+
return Score(name, 0, 0, 0, 0, 0, 0, 0, 0, 0)
103106
words = text.split()
104107
payoff = 1.8 if len(words) > 220 else 1.5
105108
deterministic = 1.1 if any(marker in text for marker in ["Run", "Playground", "$ go run", "go run"]) else 0.8
@@ -110,7 +113,7 @@ def score_external_literate_page(name: str, url: str, language_markers: tuple[st
110113
navigation = 0.8 if any(marker in text for marker in ["Next", "Previous", "Rust By Example", "Go by Example"]) else 0.4
111114
layout = 1.0
112115
total = round(payoff + deterministic + idiom + literate + output + navigation + layout, 2)
113-
return Score(name, total, payoff, deterministic, idiom, literate, output, navigation, layout)
116+
return Score(name, total, payoff, deterministic, idiom, literate, output, navigation, 0.0, layout)
114117

115118

116119
def score_gobyexample_page(slug: str) -> Score:
@@ -123,9 +126,9 @@ def score_rust_by_example_page(slug: str) -> Score:
123126

124127
def print_table(title: str, scores: list[Score]) -> None:
125128
print(f"\n{title}")
126-
print("name,total,payoff,deterministic,idiom,literate,output,navigation,layout")
129+
print("name,total,payoff,deterministic,idiom,literate,output,navigation,graph,layout")
127130
for s in scores:
128-
print(f"{s.name},{s.total:.2f},{s.payoff:.1f},{s.deterministic:.1f},{s.idiom:.1f},{s.literate:.1f},{s.output:.1f},{s.navigation:.1f},{s.layout:.1f}")
131+
print(f"{s.name},{s.total:.2f},{s.payoff:.1f},{s.deterministic:.1f},{s.idiom:.1f},{s.literate:.1f},{s.output:.1f},{s.navigation:.1f},{s.graph:.1f},{s.layout:.1f}")
129132
print(f"average,{mean(s.total for s in scores):.2f}")
130133

131134

src/app.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import contextlib
4+
import difflib
45
import html
56
import io
67
import json
@@ -29,6 +30,36 @@ def get_example(slug):
2930
return EXAMPLES_BY_SLUG.get(slug)
3031

3132

33+
def _example_index(slug):
34+
for index, example in enumerate(EXAMPLES):
35+
if example["slug"] == slug:
36+
return index
37+
return -1
38+
39+
40+
def _see_also_label(source_slug, target_slug):
41+
explicit = SEE_ALSO_EDGE_LABELS.get((source_slug, target_slug))
42+
if explicit:
43+
return explicit
44+
source = get_example(source_slug)
45+
target = get_example(target_slug)
46+
if source and target and source.get("section") == target.get("section"):
47+
return "related"
48+
source_index = _example_index(source_slug)
49+
target_index = _example_index(target_slug)
50+
if target_index >= 0 and source_index >= 0 and target_index < source_index:
51+
return "prerequisite"
52+
return "next depth"
53+
54+
55+
def _recommended_examples(slug, limit=4):
56+
matches = difflib.get_close_matches(slug, [example["slug"] for example in EXAMPLES], n=limit, cutoff=0.2)
57+
recommendations = [get_example(match) for match in matches]
58+
if not recommendations:
59+
recommendations = EXAMPLES[:limit]
60+
return [example for example in recommendations if example is not None][:limit]
61+
62+
3263
def build_dynamic_worker_code(example_code: str) -> str:
3364
"""Build a Python Dynamic Worker module that executes one example.
3465
@@ -60,6 +91,21 @@ async def fetch(self, request):
6091
_TEMPLATE_CACHE = {}
6192
SITE_URL = "https://www.pythonbyexample.dev"
6293

94+
SEE_ALSO_EDGE_LABELS = {
95+
("break-and-continue", "loop-else"): "contrast",
96+
("assignment-expressions", "conditionals"): "contrast",
97+
("yield-from", "generators"): "prerequisite",
98+
("async-iteration-and-context", "async-await"): "prerequisite",
99+
("delete-statements", "mutability"): "shared mechanism",
100+
("positional-only-parameters", "keyword-only-arguments"): "contrast",
101+
("assertions", "exceptions"): "alternative",
102+
("exception-chaining", "exceptions"): "builds on",
103+
("exception-groups", "exceptions"): "alternative",
104+
("operators-and-literals", "numbers"): "related syntax",
105+
("operators-and-literals", "strings"): "related syntax",
106+
("operators-and-literals", "sets"): "related syntax",
107+
}
108+
63109

64110
FAVICON_SVG = '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Python By Example">
65111
<rect width="64" height="64" rx="14" fill="#F5F1EB"/>
@@ -305,7 +351,7 @@ def render_example_page(example, output=None, code=None, execution_time_ms=None)
305351
notes_html = "".join(f"<li>{note}</li>" for note in notes)
306352
see_also_examples = [get_example(slug) for slug in example.get("see_also", [])]
307353
see_also_links = "".join(
308-
f'<li><a class="text-link" href="/examples/{html.escape(item["slug"])}">{html.escape(item["title"])}</a></li>'
354+
f'<li><span class="see-also-label">{html.escape(_see_also_label(example["slug"], item["slug"]))}</span> <a class="text-link" href="/examples/{html.escape(item["slug"])}">{html.escape(item["title"])}</a></li>'
309355
for item in see_also_examples
310356
if item is not None
311357
)
@@ -364,7 +410,12 @@ def route(url: str, method: str = "GET") -> AppResponse:
364410
slug = path.split("/", 2)[2]
365411
example = get_example(slug)
366412
if example is None:
367-
return AppResponse(_layout("Not Found", "<h1>Example not found</h1>"), status=404)
413+
recommendations = "".join(
414+
f'<li><a class="text-link" href="/examples/{html.escape(item["slug"])}">{html.escape(item["title"])}</a></li>'
415+
for item in _recommended_examples(slug)
416+
)
417+
body = f'<h1>Example not found</h1><p class="meta">Try one of these nearby examples.</p><section class="see-also"><h2>Recommended examples</h2><ul>{recommendations}</ul></section>'
418+
return AppResponse(_layout("Not Found", body), status=404)
368419
return AppResponse(
369420
render_example_page(example), headers={"Content-Type": "text/html; charset=utf-8"}
370421
)

src/asset_manifest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Generated by scripts/fingerprint_assets.py. Do not edit by hand.
2-
ASSET_PATHS = {'SITE_CSS': '/site.dc51488c4ed9.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.dd81f5171b14.js'}
3-
HTML_CACHE_VERSION = '4f2e027de304'
2+
ASSET_PATHS = {'SITE_CSS': '/site.dfa9bd8042a1.css', 'SYNTAX_JS': '/syntax-highlight.3b6c7f730d46.js', 'EDITOR_JS': '/editor.dd81f5171b14.js'}
3+
HTML_CACHE_VERSION = '37b3e0c8e92b'

tests/test_app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,11 @@ def test_see_also_links_form_valid_example_graph(self):
195195
self.assertTrue(set(example["see_also"]).issubset(slugs), set(example["see_also"]) - slugs)
196196
html = render_example_page(get_example("break-and-continue"))
197197
self.assertIn("See also", html)
198+
self.assertIn("contrast", html)
198199
self.assertIn('/examples/loop-else', html)
200+
missing = route("https://example.com/examples/break-continue")
201+
self.assertEqual(missing.status, 404)
202+
self.assertIn("Recommended examples", missing.body)
199203

200204
def test_examples_are_in_learning_order_and_link_supported_python_docs(self):
201205
examples = list_examples()

0 commit comments

Comments
 (0)