standardgraph 1.2.3__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: standardgraph
3
- Version: 1.2.3
4
- Summary: 158,000+ education standards (math, science, ELA, social studies, CS, arts) across 300 curriculum systems, cross-referenced via NLP — accessible as a Claude MCP server
3
+ Version: 1.3.0
4
+ Summary: 162,000+ education standards (math, science, ELA, social studies, CS, arts) across 310 curriculum systems, cross-referenced via NLP — accessible as a Claude MCP server
5
5
  Author: StandardGraph
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/swoopeagle/standardgraph
@@ -24,7 +24,7 @@ Requires-Dist: numpy>=1.26.0
24
24
 
25
25
  <!-- mcp-name: io.github.swoopeagle/standardgraph -->
26
26
 
27
- **158,000+ education standards across 300 curriculum systems, accessible as a Claude MCP server.**
27
+ **154,000+ education standards across 300 curriculum systems, accessible as a Claude MCP server.**
28
28
 
29
29
  Covers seven subjects — Mathematics, Science, ELA, Social Studies, Computer Science, Arts, and World Languages — across the US (CCSS + all 50 states), Canada, Australia, UK, Singapore, Japan, New Zealand, Ireland, Hong Kong, India, Ghana, South Africa, Rwanda, Cambridge International, IB MYP/DP, AP, and more. Standards are cross-referenced to subject hubs (CCSS for math, NGSS for science, etc.) via semantic similarity, with LLM quality scores on the strongest mappings.
30
30
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  <!-- mcp-name: io.github.swoopeagle/standardgraph -->
4
4
 
5
- **158,000+ education standards across 300 curriculum systems, accessible as a Claude MCP server.**
5
+ **154,000+ education standards across 300 curriculum systems, accessible as a Claude MCP server.**
6
6
 
7
7
  Covers seven subjects — Mathematics, Science, ELA, Social Studies, Computer Science, Arts, and World Languages — across the US (CCSS + all 50 states), Canada, Australia, UK, Singapore, Japan, New Zealand, Ireland, Hong Kong, India, Ghana, South Africa, Rwanda, Cambridge International, IB MYP/DP, AP, and more. Standards are cross-referenced to subject hubs (CCSS for math, NGSS for science, etc.) via semantic similarity, with LLM quality scores on the strongest mappings.
8
8
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "standardgraph"
3
- version = "1.2.3"
4
- description = "158,000+ education standards (math, science, ELA, social studies, CS, arts) across 300 curriculum systems, cross-referenced via NLP — accessible as a Claude MCP server"
3
+ version = "1.3.0"
4
+ description = "162,000+ education standards (math, science, ELA, social studies, CS, arts) across 310 curriculum systems, cross-referenced via NLP — accessible as a Claude MCP server"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
7
7
  license = "MIT"
@@ -28,6 +28,7 @@ Repository = "https://github.com/swoopeagle/standardgraph"
28
28
 
29
29
  [project.scripts]
30
30
  standardgraph = "common_core.server:main"
31
+ standardgraph-serve = "common_core.server:serve_http"
31
32
 
32
33
  [build-system]
33
34
  requires = ["setuptools>=61"]
@@ -1,4 +1,5 @@
1
1
  """StandardGraph MCP server — education standards across 7 subjects."""
2
+ import functools
2
3
  import json
3
4
  import re
4
5
  import sqlite3
@@ -215,6 +216,102 @@ def _grade_key(g: str) -> int:
215
216
  return 99
216
217
 
217
218
 
219
+ # Separator between the two ends of a grade range ('3-6', '3–6', '6 to 8').
220
+ _RANGE_SEP = re.compile(r"\s*(?:-|–|—|\.\.|to|through)\s*", re.I)
221
+
222
+
223
+ def _coerce_grade(v) -> str | None:
224
+ """Map an int/str grade to a canonical GRADE_ORDER code, else None.
225
+
226
+ Accepts ints, numeric strings ('5', '5.0'), grade codes ('K', 'HS',
227
+ case-insensitive) and high-school year numbers (9–12 → 'HS').
228
+ """
229
+ if v is None:
230
+ return None
231
+ s = str(v).strip().upper()
232
+ s = re.sub(r"^(?:GRADES?|GR)\.?\s*", "", s) # 'Grade 3' / 'Gr. 3' → '3'
233
+ if s in GRADE_ORDER:
234
+ return s
235
+ if s in ("K", "KG", "KINDERGARTEN"):
236
+ return "K"
237
+ if s in ("HS", "HIGH SCHOOL"):
238
+ return "HS"
239
+ try:
240
+ n = int(float(s))
241
+ except (ValueError, TypeError):
242
+ return None
243
+ if str(n) in GRADE_ORDER:
244
+ return str(n)
245
+ return "HS" if n >= 9 else "K" if n < 0 else None
246
+
247
+
248
+ def _norm_grade_bounds(grade_start, grade_end) -> tuple[str | None, str | None]:
249
+ """Coerce loose grade_start/grade_end args into canonical grade-code bounds.
250
+
251
+ Tolerates a range string passed in either slot — LLM callers frequently send
252
+ grade_start='3-6' despite the integer signature, which previously slipped past
253
+ _grade_key as 99 and silently filtered out every grade. Split it into two ends.
254
+ """
255
+ for val in (grade_start, grade_end):
256
+ if isinstance(val, str) and _RANGE_SEP.search(val.strip()):
257
+ parts = _RANGE_SEP.split(val.strip(), maxsplit=1)
258
+ if len(parts) == 2 and parts[0] and parts[1]:
259
+ return _coerce_grade(parts[0]), _coerce_grade(parts[1])
260
+ return _coerce_grade(grade_start), _coerce_grade(grade_end)
261
+
262
+
263
+ # A single-letter CCSS cluster segment sitting directly before the final numeric
264
+ # ordinal (e.g. the 'A' in '6.RP.A.3'). The DB is inconsistent about retaining it
265
+ # — most IDs keep it ('5.NF.A.2') but some dropped it on ingest ('6.RP.3') — so
266
+ # lookups normalise it away to match either form.
267
+ _CLUSTER_LETTER = re.compile(r"\.[A-Z](?=\.\d)")
268
+
269
+
270
+ def _loose_id(sid: str) -> str:
271
+ # Upper-case so the compare is case-insensitive and lowercase cluster
272
+ # letters ('6.rp.a.3') still match the [A-Z] cluster pattern.
273
+ return _CLUSTER_LETTER.sub("", sid.upper())
274
+
275
+
276
+ def _resolve_id(conn: sqlite3.Connection, sid: str, system: str) -> str | None:
277
+ """Return the canonical stored ID matching sid, or None.
278
+
279
+ Tries, in order: exact match, case-insensitive exact match, then a
280
+ cluster-letter-tolerant loose match (so '6.RP.A.3' finds a stored
281
+ '6.RP.3' and vice versa, regardless of case). Each tier is retried with
282
+ trailing punctuation stripped ('6.RP.A.3.' pasted from prose) — but the
283
+ raw ID is always tried first, so IDs that legitimately end in punctuation
284
+ (e.g. Alberta 'CA-AB.MATH.K.MAT.5.1.3.a.') still match exactly. Shared by
285
+ lookup_standard and map_standard so both tolerate the same ID drift.
286
+ """
287
+ candidates = [sid]
288
+ stripped = sid.rstrip(" .,;:")
289
+ if stripped and stripped != sid:
290
+ candidates.append(stripped)
291
+
292
+ for cand in candidates:
293
+ row = conn.execute("SELECT id FROM standards WHERE id = ?", (cand,)).fetchone()
294
+ if row:
295
+ return row[0]
296
+ row = conn.execute(
297
+ "SELECT id FROM standards WHERE id = ? COLLATE NOCASE", (cand,)
298
+ ).fetchone()
299
+ if row:
300
+ return row[0]
301
+
302
+ for cand in candidates:
303
+ target = _loose_id(cand)
304
+ match = next(
305
+ (cid for (cid,) in conn.execute(
306
+ "SELECT id FROM standards WHERE system = ?", (system,))
307
+ if _loose_id(cid) == target),
308
+ None,
309
+ )
310
+ if match:
311
+ return match
312
+ return None
313
+
314
+
218
315
  # ── Embedding ─────────────────────────────────────────────────────────────────
219
316
 
220
317
  def _embed_query(text: str) -> np.ndarray:
@@ -352,6 +449,51 @@ def _fts_search(
352
449
  return results
353
450
 
354
451
 
452
+ # ── Relationship helpers (shared by lookup_standard + get_learning_path) ──────
453
+
454
+ # Minimum confidence_score for an LLM-validated edge to count as a HARD (default)
455
+ # prerequisite. SOFT edges are stored at 0.5 and only surfaced with include_soft.
456
+ _HARD_CONF = 0.9
457
+ _SOFT_CONF = 0.5
458
+
459
+
460
+ def _related(conn, sid: str, relationship: str, prefer_validated: bool):
461
+ """Return (ids, source) for a standard's prerequisite/successor edges.
462
+
463
+ When prefer_validated is True and any method='llm_validated' edges exist for
464
+ this standard, return those (source='llm_validated'); otherwise fall back to
465
+ the grade-heuristic edges (source='grade_heuristic'). Falling back preserves
466
+ non-empty prerequisite lists for standards the pilot never re-validated.
467
+ """
468
+ if prefer_validated:
469
+ val = [r[0] for r in conn.execute(
470
+ "SELECT target_id FROM standard_relationships "
471
+ "WHERE source_id=? AND relationship=? AND method='llm_validated' "
472
+ "ORDER BY confidence_score DESC, target_id",
473
+ (sid, relationship)).fetchall()]
474
+ if val:
475
+ return val, "llm_validated"
476
+ allrows = [r[0] for r in conn.execute(
477
+ "SELECT target_id FROM standard_relationships "
478
+ "WHERE source_id=? AND relationship=? ORDER BY target_id",
479
+ (sid, relationship)).fetchall()]
480
+ return allrows, "grade_heuristic"
481
+
482
+
483
+ def _parse_prereq_note(notes: str | None) -> tuple[str | None, str | None]:
484
+ """Split a validated-edge note into (strength, rationale).
485
+
486
+ Stored form: 'llm_prereq cosine=0.83 hard: <why>' — surfaced so callers can
487
+ show *why* a prerequisite was included (the provenance/trust story).
488
+ """
489
+ if not notes:
490
+ return None, None
491
+ m = re.search(r"\b(hard|soft):\s*(.*)$", notes, re.S)
492
+ if m:
493
+ return m.group(1), m.group(2).strip() or None
494
+ return None, notes.strip() or None
495
+
496
+
355
497
  # ── Tool 1: lookup_standard ───────────────────────────────────────────────────
356
498
 
357
499
  @mcp.tool()
@@ -359,6 +501,7 @@ def lookup_standard(
359
501
  standard_id: str,
360
502
  system: str = "ccss",
361
503
  include_elaborations: bool = False,
504
+ prefer_validated: bool = True,
362
505
  ) -> str:
363
506
  """Fetch the full text, domain, cluster, prerequisites, and successors for a single standard.
364
507
 
@@ -367,6 +510,10 @@ def lookup_standard(
367
510
  standard_id: full ID like 'CCSS.MATH.6.RP.A.3' or shortform '6.RP.A.3' (for CCSS).
368
511
  For other systems use the full ID, e.g. 'TX.MATH.5.3.K' or 'CA_BC.MATH.3.a'.
369
512
  system: curriculum system code (default 'ccss'). See server instructions for all codes.
513
+ prefer_validated: when True (default), return the LLM-validated prerequisite/successor
514
+ edges if any exist for this standard (higher precision, may be cross-domain);
515
+ otherwise fall back to the grade-heuristic edges. The response reports which
516
+ source was used via `prerequisites_method`.
370
517
 
371
518
  Returns the standard text, grade, domain, cluster, sub-standards (if any),
372
519
  prerequisite standard IDs from the prior grade, and successor IDs for the next grade.
@@ -374,7 +521,17 @@ def lookup_standard(
374
521
  sid = _expand_id(standard_id, system)
375
522
  conn = _db()
376
523
 
377
- row = conn.execute("SELECT * FROM standards WHERE id = ?", (sid,)).fetchone()
524
+ # Resolve to the canonical stored ID (tolerating cluster-letter/case drift),
525
+ # then use it for every downstream lookup so sub-standards, prerequisites and
526
+ # successors are keyed off the real ID rather than the drifted input.
527
+ canonical = _resolve_id(conn, sid, system)
528
+ row = (
529
+ conn.execute("SELECT * FROM standards WHERE id = ?", (canonical,)).fetchone()
530
+ if canonical
531
+ else None
532
+ )
533
+ if row:
534
+ sid = canonical
378
535
  if not row:
379
536
  # Suggest nearby IDs
380
537
  suggestions = [
@@ -393,18 +550,23 @@ def lookup_standard(
393
550
  (sid,),
394
551
  ).fetchall()
395
552
 
396
- prerequisites = [
397
- r[0] for r in conn.execute(
398
- "SELECT target_id FROM standard_relationships WHERE source_id=? AND relationship='prerequisite'",
399
- (sid,),
400
- ).fetchall()
401
- ]
402
- successors = [
403
- r[0] for r in conn.execute(
404
- "SELECT target_id FROM standard_relationships WHERE source_id=? AND relationship='successor'",
405
- (sid,),
406
- ).fetchall()
407
- ]
553
+ prerequisites, prereq_method = _related(conn, sid, "prerequisite", prefer_validated)
554
+ successors, _ = _related(conn, sid, "successor", prefer_validated)
555
+
556
+ # Provenance: when the prerequisites are LLM-validated, surface *why* each was
557
+ # included (rationale + hard/soft strength) so callers can show/trust the edge.
558
+ prereq_rationales = None
559
+ if prereq_method == "llm_validated":
560
+ prereq_rationales = {}
561
+ for r in conn.execute(
562
+ "SELECT target_id, confidence_score, notes FROM standard_relationships "
563
+ "WHERE source_id=? AND relationship='prerequisite' AND method='llm_validated'",
564
+ (sid,)).fetchall():
565
+ strength, why = _parse_prereq_note(r[2])
566
+ prereq_rationales[r[0]] = {
567
+ "strength": strength or ("hard" if (r[1] or 0) >= _HARD_CONF else "soft"),
568
+ "why": why,
569
+ }
408
570
  conn.close()
409
571
 
410
572
  return json.dumps({
@@ -416,6 +578,8 @@ def lookup_standard(
416
578
  "standard_text": std["standard_text"],
417
579
  "sub_standards": [f"{r['id']} — {r['text']}" for r in sub_stds],
418
580
  "prerequisites": prerequisites,
581
+ "prerequisites_method": prereq_method,
582
+ "prerequisite_rationales": prereq_rationales,
419
583
  "successors": successors,
420
584
  "source_url": std["source_url"],
421
585
  "elaborations": None,
@@ -518,8 +682,8 @@ def _parse_grade_filter(grade: str | list) -> set[str]:
518
682
  def get_progression(
519
683
  concept: str,
520
684
  system: str = "ccss",
521
- grade_start: int | None = None,
522
- grade_end: int | None = None,
685
+ grade_start: int | str | None = None,
686
+ grade_end: int | str | None = None,
523
687
  ) -> str:
524
688
  """Show how a math concept is introduced and built upon across grade levels.
525
689
 
@@ -529,11 +693,13 @@ def get_progression(
529
693
  concept: plain English name of the math concept (e.g. 'fractions', 'linear equations',
530
694
  'place value', 'geometric transformations').
531
695
  system: curriculum to trace (default 'ccss'). Try 'cambridge' or 'ib-myp' for comparison.
532
- grade_start / grade_end: optional integer bounds to narrow the range (e.g. 3 and 8).
696
+ grade_start / grade_end: optional bounds to narrow the range (e.g. 3 and 8). Accepts
697
+ ints, grade codes ('K', 'HS'), or a single range string like '3-6' / '6 to 8'.
533
698
 
534
699
  Returns the top matching standards per grade, ordered K through HS, showing how the
535
700
  concept deepens over time.
536
701
  """
702
+ g_start, g_end = _norm_grade_bounds(grade_start, grade_end)
537
703
  try:
538
704
  query_vec = _embed_query(concept)
539
705
  except Exception:
@@ -550,8 +716,8 @@ def get_progression(
550
716
  continue
551
717
  by_grade.setdefault(g, []).append({"id": r["id"], "text": r["standard_text"]})
552
718
 
553
- gr_start = str(grade_start) if grade_start is not None else "K"
554
- gr_end = str(grade_end) if grade_end is not None else "HS"
719
+ gr_start = g_start or "K"
720
+ gr_end = g_end or "HS"
555
721
 
556
722
  return json.dumps({
557
723
  "concept": concept,
@@ -580,9 +746,9 @@ def get_progression(
580
746
  std = dict(row)
581
747
  g = std["grade"]
582
748
 
583
- if grade_start is not None and _grade_key(g) < _grade_key(str(grade_start)):
749
+ if g_start is not None and _grade_key(g) < _grade_key(g_start):
584
750
  continue
585
- if grade_end is not None and _grade_key(g) > _grade_key(str(grade_end)):
751
+ if g_end is not None and _grade_key(g) > _grade_key(g_end):
586
752
  continue
587
753
 
588
754
  by_grade.setdefault(g, []).append({
@@ -593,8 +759,8 @@ def get_progression(
593
759
 
594
760
  conn.close()
595
761
 
596
- gr_start = str(grade_start) if grade_start is not None else "K"
597
- gr_end = str(grade_end) if grade_end is not None else "HS"
762
+ gr_start = g_start or "K"
763
+ gr_end = g_end or "HS"
598
764
 
599
765
  stages = []
600
766
  for g in sorted(by_grade.keys(), key=_grade_key):
@@ -612,7 +778,165 @@ def get_progression(
612
778
  }, indent=2)
613
779
 
614
780
 
615
- # ── Tool 4: map_standard ──────────────────────────────────────────────────────
781
+ # ── Tool 4: get_learning_path ─────────────────────────────────────────────────
782
+
783
+ @mcp.tool()
784
+ def get_learning_path(
785
+ target: str,
786
+ system: str = "ccss",
787
+ from_standard: str | None = None,
788
+ max_depth: int = 20,
789
+ include_soft: bool = False,
790
+ ) -> str:
791
+ """Build an ordered, grade-increasing sequence of standards to learn to reach a target.
792
+
793
+ Walks the LLM-validated prerequisite graph backward from the target standard and
794
+ returns every prerequisite (transitively) as a topologically ordered study plan —
795
+ the substrate for self-paced acceleration ("what do I need before calculus?").
796
+ Only method='llm_validated' edges are traversed, so the path is far cleaner than the
797
+ raw grade-adjacency graph and includes genuine cross-domain dependencies.
798
+
799
+ target: the standard to work toward — full ID 'CCSS.MATH.HSF.LE.A.1.b' or shortform.
800
+ system: curriculum system code (default 'ccss'; validated edges currently exist for CCSS math).
801
+ from_standard: optional — the standard the learner has already mastered. When given, the
802
+ path is pruned to the sub-sequence between that standard and the target.
803
+ max_depth: max prerequisite levels to walk back (default 20; guards pathological depth).
804
+ include_soft: when False (default) only HARD prerequisites (direct building blocks) are
805
+ followed; when True, SOFT (helpful-background) edges are included too.
806
+
807
+ Returns the resolved target, an ordered `path` (each node with id/grade/domain/text and its
808
+ immediate in-path prerequisites), plus counts. If the target has no validated prerequisites
809
+ the path is just the target with an explanatory note.
810
+ """
811
+ conn = _db()
812
+ tgt = _resolve_id(conn, _expand_id(target, system), system)
813
+ if not tgt:
814
+ conn.close()
815
+ return json.dumps({"error": "standard_not_found", "queried_id": _expand_id(target, system)})
816
+
817
+ min_conf = _SOFT_CONF if include_soft else _HARD_CONF
818
+ # (learner, prereq) -> {strength, why, confidence} for provenance in the output.
819
+ edge_meta: dict[tuple[str, str], dict] = {}
820
+
821
+ def prereqs_of(node: str) -> list[str]:
822
+ rows = conn.execute(
823
+ "SELECT target_id, confidence_score, notes FROM standard_relationships "
824
+ "WHERE source_id=? AND relationship='prerequisite' "
825
+ "AND method='llm_validated' AND confidence_score >= ?",
826
+ (node, min_conf)).fetchall()
827
+ out = []
828
+ for p, conf, notes in rows:
829
+ strength, why = _parse_prereq_note(notes)
830
+ edge_meta[(node, p)] = {
831
+ "strength": strength or ("hard" if (conf or 0) >= _HARD_CONF else "soft"),
832
+ "why": why,
833
+ "confidence": conf,
834
+ }
835
+ out.append(p)
836
+ return out
837
+
838
+ # Reverse-BFS the prerequisite closure of the target, bounded by max_depth.
839
+ seen = {tgt}
840
+ edges: dict[str, list[str]] = {}
841
+ frontier = [tgt]
842
+ depth = 0
843
+ while frontier and depth < max_depth:
844
+ nxt: list[str] = []
845
+ for n in frontier:
846
+ ps = prereqs_of(n)
847
+ edges[n] = ps
848
+ for p in ps:
849
+ if p not in seen:
850
+ seen.add(p)
851
+ nxt.append(p)
852
+ frontier = nxt
853
+ depth += 1
854
+
855
+ # Optional from_standard pruning: keep only nodes on a chain from_standard → target.
856
+ from_resolved = None
857
+ from_reachable = None
858
+ if from_standard:
859
+ src = _resolve_id(conn, _expand_id(from_standard, system), system)
860
+ from_resolved = src
861
+ from_reachable = bool(src and src in seen)
862
+ if from_reachable:
863
+ # Forward reachability from src via validated successor edges, within the closure.
864
+ fwd = {src}
865
+ fr = [src]
866
+ while fr:
867
+ nx: list[str] = []
868
+ for n in fr:
869
+ for r in conn.execute(
870
+ "SELECT target_id FROM standard_relationships "
871
+ "WHERE source_id=? AND relationship='successor' "
872
+ "AND method='llm_validated' AND confidence_score >= ?",
873
+ (n, min_conf)).fetchall():
874
+ s = r[0]
875
+ if s in seen and s not in fwd:
876
+ fwd.add(s)
877
+ nx.append(s)
878
+ fr = nx
879
+ keep = (fwd & seen) | {tgt}
880
+ seen = {n for n in seen if n in keep}
881
+
882
+ # Materialise node rows and order by grade (every validated edge increases grade,
883
+ # so grade order is a valid topological order; ties broken by id for determinism).
884
+ nodes = []
885
+ for n in seen:
886
+ row = conn.execute(
887
+ "SELECT id, grade, domain, standard_text FROM standards WHERE id=?", (n,)
888
+ ).fetchone()
889
+ if row:
890
+ nodes.append(dict(row))
891
+ conn.close()
892
+ nodes.sort(key=lambda d: (_grade_key(d["grade"]), d["id"]))
893
+
894
+ setids = {d["id"] for d in nodes}
895
+
896
+ def _prereq_entries(learner: str):
897
+ entries = []
898
+ for p in edges.get(learner, []):
899
+ if p not in setids:
900
+ continue
901
+ meta = edge_meta.get((learner, p), {})
902
+ entries.append({
903
+ "id": p,
904
+ "strength": meta.get("strength"),
905
+ "why": meta.get("why"),
906
+ })
907
+ return entries
908
+
909
+ path = [{
910
+ "id": d["id"],
911
+ "grade": d["grade"],
912
+ "domain": d["domain"],
913
+ "standard_text": d["standard_text"],
914
+ "prerequisites_in_path": _prereq_entries(d["id"]),
915
+ } for d in nodes]
916
+
917
+ result = {
918
+ "target": tgt,
919
+ "system": system,
920
+ "edge_strength": "hard+soft" if include_soft else "hard_only",
921
+ "path_length": len(path),
922
+ "path": path,
923
+ }
924
+ if from_standard:
925
+ result["from_standard"] = from_resolved or _expand_id(from_standard, system)
926
+ result["from_standard_reachable"] = from_reachable
927
+ # Notes, most-specific first.
928
+ if from_standard and from_reachable and len(path) == 1:
929
+ # from_standard prunes everything else away → learner is already at/past the target.
930
+ result["note"] = "from_standard already reaches the target — no intermediate steps needed"
931
+ elif len(path) == 1 and path[0]["id"] == tgt:
932
+ result["note"] = ("no validated prerequisites found for this target"
933
+ + (" at hard strength — retry with include_soft=True" if not include_soft else ""))
934
+ elif from_standard and from_resolved and not from_reachable:
935
+ result["note"] = "from_standard is not a validated prerequisite of the target; returning the full prerequisite path"
936
+ return json.dumps(result, indent=2)
937
+
938
+
939
+ # ── Tool 5: map_standard ──────────────────────────────────────────────────────
616
940
 
617
941
  @mcp.tool()
618
942
  def map_standard(
@@ -647,11 +971,19 @@ def map_standard(
647
971
  sid = _expand_id(standard_id, from_system)
648
972
  conn = _db()
649
973
 
650
- src = conn.execute("SELECT * FROM standards WHERE id=?", (sid,)).fetchone()
974
+ # Same cluster-letter/case tolerance as lookup_standard, then pin sid to the
975
+ # canonical ID so the crosswalk/embedding queries below hit the stored rows.
976
+ canonical = _resolve_id(conn, sid, from_system)
977
+ src = (
978
+ conn.execute("SELECT * FROM standards WHERE id=?", (canonical,)).fetchone()
979
+ if canonical
980
+ else None
981
+ )
651
982
  if not src:
652
983
  conn.close()
653
984
  return json.dumps({"error": "standard_not_found", "queried_id": sid})
654
985
 
986
+ sid = canonical
655
987
  src_dict = dict(src)
656
988
 
657
989
  # ── 1. Precomputed crosswalk above threshold ───────────────────────────────
@@ -858,7 +1190,49 @@ def map_standard(
858
1190
  return json.dumps(response, indent=2)
859
1191
 
860
1192
 
861
- # ── Tool 5: list_systems ──────────────────────────────────────────────────────
1193
+ # ── Tool 6: list_systems ──────────────────────────────────────────────────────
1194
+
1195
+ @functools.lru_cache(maxsize=1)
1196
+ def _systems_snapshot():
1197
+ """Per-process cache of system-level aggregates + corpus totals.
1198
+
1199
+ These come from full-table scans (GROUP BY over ~157k standards, a JOIN+GROUP
1200
+ over ~103k crosswalks, a COUNT over ~3.2M relationships) that are *static* for a
1201
+ server's lifetime — the DB is read-only while serving and the hosted process
1202
+ restarts on any DB refresh. Caching turns list_systems from ~250ms into ~0.1ms
1203
+ on repeat calls and removes it as a GIL bottleneck under concurrent load.
1204
+ Returns (rows, totals); rows carry a private `subject_set` for the subject filter.
1205
+ """
1206
+ conn = _db()
1207
+ systems = conn.execute(
1208
+ "SELECT system, GROUP_CONCAT(DISTINCT subject) AS subjects, COUNT(id) AS standards "
1209
+ "FROM standards GROUP BY system ORDER BY system").fetchall()
1210
+ xwalk_counts = dict(conn.execute(
1211
+ "SELECT s.system, COUNT(*) FROM crosswalk_mappings cm "
1212
+ "INNER JOIN standards s ON s.id = cm.source_id GROUP BY s.system").fetchall())
1213
+ totals = {
1214
+ "systems": len(systems),
1215
+ "standards": conn.execute("SELECT COUNT(*) FROM standards").fetchone()[0],
1216
+ "embeddings": conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0],
1217
+ "crosswalk_mappings": conn.execute("SELECT COUNT(*) FROM crosswalk_mappings").fetchone()[0],
1218
+ "relationships": conn.execute("SELECT COUNT(*) FROM standard_relationships").fetchone()[0],
1219
+ }
1220
+ conn.close()
1221
+
1222
+ rows = []
1223
+ for r in systems:
1224
+ m = _meta(r["system"])
1225
+ rows.append({
1226
+ "system": r["system"],
1227
+ "subjects": r["subjects"],
1228
+ "subject_set": set((r["subjects"] or "").split(",")),
1229
+ "standards": r["standards"],
1230
+ "crosswalked": xwalk_counts.get(r["system"], 0),
1231
+ "country": m.get("country"),
1232
+ "region": m.get("region", "") or None,
1233
+ })
1234
+ return rows, totals
1235
+
862
1236
 
863
1237
  @mcp.tool()
864
1238
  def list_systems(
@@ -876,63 +1250,19 @@ def list_systems(
876
1250
 
877
1251
  Returns: system code, subjects covered, standard count, crosswalk coverage, country, region.
878
1252
  """
879
- conn = _db()
880
-
881
- # Build subject-filtered system list from the standards table
882
- if subject:
883
- subject_systems = {
884
- r[0] for r in conn.execute(
885
- "SELECT DISTINCT system FROM standards WHERE subject = ?", (subject,)
886
- ).fetchall()
887
- }
888
- else:
889
- subject_systems = None
890
-
891
- # Counts split into separate fast queries to avoid expensive 3-way JOIN.
892
- systems = conn.execute(
893
- """SELECT system,
894
- GROUP_CONCAT(DISTINCT subject) AS subjects,
895
- COUNT(id) AS standards
896
- FROM standards
897
- GROUP BY system
898
- ORDER BY system"""
899
- ).fetchall()
900
- xwalk_counts = dict(conn.execute(
901
- "SELECT s.system, COUNT(*) FROM crosswalk_mappings cm "
902
- "INNER JOIN standards s ON s.id = cm.source_id GROUP BY s.system"
903
- ).fetchall())
904
-
905
- total_std = conn.execute("SELECT COUNT(*) FROM standards").fetchone()[0]
906
- total_emb = conn.execute("SELECT COUNT(*) FROM embeddings").fetchone()[0]
907
- total_xwalk = conn.execute("SELECT COUNT(*) FROM crosswalk_mappings").fetchone()[0]
908
- total_rel = conn.execute("SELECT COUNT(*) FROM standard_relationships").fetchone()[0]
909
- conn.close()
1253
+ all_rows, totals = _systems_snapshot()
910
1254
 
911
1255
  system_rows = []
912
- for r in systems:
913
- if subject_systems is not None and r["system"] not in subject_systems:
1256
+ for r in all_rows:
1257
+ if subject and subject not in r["subject_set"]:
914
1258
  continue
915
- m = _meta(r["system"])
916
- sys_region = m.get("region", "")
917
- if region and region.lower() not in sys_region.lower():
1259
+ if region and region.lower() not in (r["region"] or "").lower():
918
1260
  continue
919
- system_rows.append({
920
- "system": r["system"],
921
- "subjects": r["subjects"],
922
- "standards": r["standards"],
923
- "crosswalked": xwalk_counts.get(r["system"], 0),
924
- "country": m.get("country"),
925
- "region": sys_region or None,
926
- })
1261
+ system_rows.append({k: r[k] for k in
1262
+ ("system", "subjects", "standards", "crosswalked", "country", "region")})
927
1263
 
928
1264
  return json.dumps({
929
- "totals": {
930
- "systems": len(systems),
931
- "standards": total_std,
932
- "embeddings": total_emb,
933
- "crosswalk_mappings": total_xwalk,
934
- "relationships": total_rel,
935
- },
1265
+ "totals": totals,
936
1266
  "filters_applied": {k: v for k, v in {"subject": subject, "region": region}.items() if v},
937
1267
  "matched_systems": len(system_rows),
938
1268
  "systems": system_rows,
@@ -943,5 +1273,27 @@ def main() -> None:
943
1273
  mcp.run()
944
1274
 
945
1275
 
1276
+ def serve_http() -> None:
1277
+ """Run the server over streamable-HTTP for remote/hosted deployments.
1278
+
1279
+ Reads SG_HTTP_HOST / SG_HTTP_PORT (defaults 0.0.0.0:8010). Intended to sit
1280
+ behind a TLS-terminating tunnel (Cloudflare Tunnel / Tailscale Funnel), so
1281
+ Host/Origin are wildcarded to let the proxied hostname through FastMCP's
1282
+ DNS-rebinding guard. Only expose read-only, non-secret data this way.
1283
+ """
1284
+ import os
1285
+
1286
+ host = os.getenv("SG_HTTP_HOST", "0.0.0.0")
1287
+ port = int(os.getenv("SG_HTTP_PORT", "8010"))
1288
+ print(f"StandardGraph HTTP MCP → http://{host}:{port}/mcp (DB={DB_PATH})")
1289
+ mcp.run(
1290
+ transport="http",
1291
+ host=host,
1292
+ port=port,
1293
+ allowed_hosts=["*"],
1294
+ allowed_origins=["*"],
1295
+ )
1296
+
1297
+
946
1298
  if __name__ == "__main__":
947
1299
  main()
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: standardgraph
3
- Version: 1.2.3
4
- Summary: 158,000+ education standards (math, science, ELA, social studies, CS, arts) across 300 curriculum systems, cross-referenced via NLP — accessible as a Claude MCP server
3
+ Version: 1.3.0
4
+ Summary: 162,000+ education standards (math, science, ELA, social studies, CS, arts) across 310 curriculum systems, cross-referenced via NLP — accessible as a Claude MCP server
5
5
  Author: StandardGraph
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/swoopeagle/standardgraph
@@ -24,7 +24,7 @@ Requires-Dist: numpy>=1.26.0
24
24
 
25
25
  <!-- mcp-name: io.github.swoopeagle/standardgraph -->
26
26
 
27
- **158,000+ education standards across 300 curriculum systems, accessible as a Claude MCP server.**
27
+ **154,000+ education standards across 300 curriculum systems, accessible as a Claude MCP server.**
28
28
 
29
29
  Covers seven subjects — Mathematics, Science, ELA, Social Studies, Computer Science, Arts, and World Languages — across the US (CCSS + all 50 states), Canada, Australia, UK, Singapore, Japan, New Zealand, Ireland, Hong Kong, India, Ghana, South Africa, Rwanda, Cambridge International, IB MYP/DP, AP, and more. Standards are cross-referenced to subject hubs (CCSS for math, NGSS for science, etc.) via semantic similarity, with LLM quality scores on the strongest mappings.
30
30
 
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  standardgraph = common_core.server:main
3
+ standardgraph-serve = common_core.server:serve_http
File without changes