scripts.spec_word_cloud
Reference › scripts › spec_word_cloud
scripts/spec_word_cloud.py is a term-prominence tool, an MVP word cloud, for the specs and requirements of orchestrata-ultimate: it shows which terms dominate the specification corpus. A human runs it. With --apply-to-graph it also matches the top terms against entity names in the semantic graph, using orchestrata_ultimate.semantic_graph.adapters.memgraph, orchestrata_ultimate.semantic_graph.config and orchestrata_ultimate.semantic_graph.repository.
discover_documents concatenates the text of each spec directory under the specs directory. tokenize counts terms per document, splitting CamelCase and snake_case identifiers into words (is_identifier_token, split_identifier), and compute_tfidf produces a global ranking and a per-spec ranking. write_json_report writes the JSON, and build_svg renders a self-contained SVG word cloud with greedy row packing, sizing fonts by weight. For the graph step, entity_name_candidates and _canonical_form normalise names to lowercase alphanumeric strings so terms can be matched. Only the top-N terms are matched, because matching the full ranking against thousands of entities runs for hours. Options include the specs directory, --output-json, --output-svg, --top and --apply-to-graph. It prints the document count, the output paths and the top ten global terms.
At a glance
| Source | scripts/spec_word_cloud.py |
| Lines of code | 462 |
| Classes | 1 |
| Functions | 16 |
| Imports (internal) | orchestrata_ultimate.semantic_graph.adapters.memgraph, orchestrata_ultimate.semantic_graph.config, orchestrata_ultimate.semantic_graph.repository |
| Imported by | none |
| Tests that import it | none |
Class diagram
Dependency diagram
API
Classes
TermStat (dataclass)
Decorators: dataclass · Line 141
A single (term, weight, count) ranking entry.
| Attribute | Type | Default |
|---|---|---|
term |
str |
|
weight |
float |
|
count |
int |
| Method | Signature | Summary |
|---|---|---|
to_dict |
to_dict() -> dict[str, Any] |
Functions
| Function | Signature | Summary |
|---|---|---|
is_identifier_token |
is_identifier_token(token: str) -> bool |
True if a token reads as a CamelCase or snake_case identifier. |
split_identifier |
split_identifier(token: str) -> list[str] |
Split a CamelCase/snake_case identifier into lowercase constituent words. |
tokenize |
tokenize(text: str) -> Counter[str] |
Tokenize text into a term->count Counter for one document. |
discover_documents |
discover_documents(specs_dir: Path) -> dict[str, str] |
Return {spec_key: concatenated_text} for each spec directory. |
compute_tfidf |
compute_tfidf(documents: dict[str, str]) -> tuple[list[TermStat], dict[str, list[TermStat]]] |
Compute global and per-spec TF-IDF rankings. |
_font_size |
_font_size(weight: float, min_weight: float, max_weight: float) -> float |
|
_estimate_text_width |
_estimate_text_width(text: str, font_size: float) -> float |
|
build_svg |
build_svg(terms: list[TermStat], max_terms: int = 80) -> str |
Render a self-contained SVG word cloud (greedy row-packing layout). |
_canonical_form |
_canonical_form(text: str) -> str |
Normalize an identifier/name to a canonical lowercase-alnum string. |
entity_name_candidates |
entity_name_candidates(entity_id: str, name_prop: str | None = None) -> set[str] |
Derive matchable name fragments from a structured entity id. |
term_matches_entity_name |
term_matches_entity_name(term: str, entity_name: str) -> bool |
True if term and entity_name refer to the same identifier, modulo case and camelCase/snake_case formatting differences. |
write_json_report |
write_json_report(output_path: Path, global_ranking: list[TermStat], per_spec: dict[str, list[TermStat]], top: int) -> None |
|
write_svg_report |
write_svg_report(output_path: Path, global_ranking: list[TermStat]) -> None |
|
apply_to_graph |
async apply_to_graph(global_ranking: list[TermStat]) -> None |
Best-effort: write a prominence property onto matching graph nodes. |
_build_arg_parser |
_build_arg_parser() -> argparse.ArgumentParser |
|
main |
main(argv: list[str] | None = None) -> int |
Source
"""Spec/requirements term-prominence tool (MVP word cloud) for orchestrata-ultimate.
Scans ``specs/*/spec.md`` + ``specs/*/requirements.md``, tokenizes the text,
splits identifiers (CamelCase / snake_case) into constituent words while ALSO
keeping the intact identifier as a 2x-weighted "object/node" term, computes a
classic TF-IDF ranking per spec-document and globally, and emits:
- a JSON term-prominence report (``plans/spec_term_prominence.json``)
- a hand-laid-out, self-contained SVG word cloud (``docs/diagrams/spec-word-cloud.svg``)
Optionally (``--apply-to-graph``), it writes a ``prominence`` property onto
matching entities in the semantic graph (Memgraph), fail-soft if the graph is
unreachable.
Weighting choice (documented per the brief): tf is the raw per-spec term count
(with the 2x boost for intact identifiers baked directly into the count, not
applied twice), idf is computed GLOBALLY as ``log(N_docs / (1 + df))`` where
df is the number of spec-documents containing the term at least once. The
per-spec weight is ``tf * idf``. The GLOBAL weight for a term is the sum of
its per-spec weights across all documents that contain it — this rewards
terms that are both frequent and either concentrated or broadly used, while
still following the classic single global idf (simplest option consistent
between per-spec and global views).
"""
from __future__ import annotations
import argparse
import json
import math
import re
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# --- stopwords -------------------------------------------------------------
_ENGLISH_STOPWORDS = frozenset("""
a an the is are of to in on for and or that this be as by with from at
it its into if when then which will shall must should may can not no
than such other these those we you your our
""".split())
_SPEC_BOILERPLATE_STOPWORDS = frozenset("""
spec specs requirement requirements section table contents overview
summary story stories us req fr nfr id ids status draft approved todo
tbd note notes example examples figure diagram
""".split())
STOPWORDS = _ENGLISH_STOPWORDS | _SPEC_BOILERPLATE_STOPWORDS
# raw word-ish tokens: letters/digits/underscore runs, must start with a letter
_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9_]*")
# camelCase boundary: lowercase/digit followed by uppercase, OR a run of
# uppercase letters followed by a lowercase (e.g. "CXPPacket" -> "CXP","Packet")
_CAMEL_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
def is_identifier_token(token: str) -> bool:
"""True if a token reads as a CamelCase or snake_case identifier."""
if "_" in token:
return True
# internal uppercase after a lowercase somewhere in the token
return bool(re.search(r"[a-z][A-Z]", token)) or bool(
re.search(r"[A-Z]{2,}[a-z]", token)
)
def split_identifier(token: str) -> list[str]:
"""Split a CamelCase/snake_case identifier into lowercase constituent words."""
parts: list[str] = []
for chunk in token.split("_"):
if not chunk:
continue
parts.extend(p for p in _CAMEL_BOUNDARY_RE.split(chunk) if p)
return [p.lower() for p in parts if p]
def tokenize(text: str) -> Counter[str]:
"""Tokenize ``text`` into a term->count Counter for one document.
Intact identifiers (CamelCase/snake_case) are counted under their
ORIGINAL casing with a 2x weight (baked into the count). Their
constituent words are ALSO counted (lowercased, normal 1x weight),
unless they are stopwords or shorter than 2 chars.
"""
counts: Counter[str] = Counter()
for match in _TOKEN_RE.finditer(text):
token = match.group(0)
if len(token) < 2:
continue
if is_identifier_token(token):
counts[token] += 2
for word in split_identifier(token):
if len(word) < 2 or word in STOPWORDS:
continue
counts[word] += 1
else:
word = token.lower()
if len(word) < 2 or word in STOPWORDS:
continue
counts[word] += 1
return counts
# --- document discovery -----------------------------------------------------
_SPEC_ID_RE = re.compile(r"SPEC-\d{3}\b")
def discover_documents(specs_dir: Path) -> dict[str, str]:
"""Return ``{spec_key: concatenated_text}`` for each spec directory.
``spec_key`` prefers an embedded ``SPEC-XXX`` id found in the document
text; falls back to the directory name otherwise.
"""
documents: dict[str, str] = {}
for spec_dir in sorted(p for p in specs_dir.iterdir() if p.is_dir()):
pieces: list[str] = []
for filename in ("spec.md", "requirements.md"):
file_path = spec_dir / filename
if file_path.exists():
pieces.append(file_path.read_text(encoding="utf-8", errors="replace"))
if not pieces:
continue
text = "\n".join(pieces)
match = _SPEC_ID_RE.search(text)
key = match.group(0) if match else spec_dir.name
documents[key] = text
return documents
# --- TF-IDF ------------------------------------------------------------------
@dataclass
class TermStat:
"""A single (term, weight, count) ranking entry."""
term: str
weight: float
count: int
def to_dict(self) -> dict[str, Any]:
return {"term": self.term, "weight": self.weight, "count": self.count}
def compute_tfidf(
documents: dict[str, str],
) -> tuple[list[TermStat], dict[str, list[TermStat]]]:
"""Compute global and per-spec TF-IDF rankings.
Returns ``(global_ranking, per_spec_rankings)``, both already sorted
descending by weight (ties broken by term for determinism).
"""
per_doc_counts: dict[str, Counter[str]] = {
key: tokenize(text) for key, text in documents.items()
}
n_docs = len(per_doc_counts)
df: Counter[str] = Counter()
for counts in per_doc_counts.values():
for term in counts:
df[term] += 1
def idf(term: str) -> float:
# Smoothed IDF (as in scikit-learn's TfidfVectorizer default): always
# non-negative, so a term's weight/prominence is never negative even
# when it appears in every document.
return math.log((1 + n_docs) / (1 + df[term])) + 1
per_spec: dict[str, list[TermStat]] = {}
global_weight: dict[str, float] = {}
global_count: Counter[str] = Counter()
for key, counts in per_doc_counts.items():
stats = []
for term, tf in counts.items():
weight = tf * idf(term)
stats.append(TermStat(term=term, weight=weight, count=tf))
global_weight[term] = global_weight.get(term, 0.0) + weight
global_count[term] += tf
stats.sort(key=lambda s: (-s.weight, s.term))
per_spec[key] = stats
global_ranking = [
TermStat(term=term, weight=global_weight[term], count=global_count[term])
for term in global_weight
]
global_ranking.sort(key=lambda s: (-s.weight, s.term))
return global_ranking, per_spec
# --- SVG word cloud ----------------------------------------------------------
_PALETTE = ["#3b6ea5", "#a5573b", "#4f8f6d", "#8a5ca8", "#b58a2e", "#5c7a8a"]
_SVG_WIDTH = 1000
_SVG_HEIGHT = 700
_MIN_FONT = 12.0
_MAX_FONT = 64.0
def _font_size(weight: float, min_weight: float, max_weight: float) -> float:
if max_weight <= min_weight:
return (_MIN_FONT + _MAX_FONT) / 2
frac = (weight - min_weight) / (max_weight - min_weight)
frac = math.sqrt(max(0.0, min(1.0, frac)))
return _MIN_FONT + frac * (_MAX_FONT - _MIN_FONT)
def _estimate_text_width(text: str, font_size: float) -> float:
# Rough average glyph width for a generic sans-serif font.
return len(text) * font_size * 0.6
def build_svg(terms: list[TermStat], max_terms: int = 80) -> str:
"""Render a self-contained SVG word cloud (greedy row-packing layout)."""
chosen = terms[:max_terms]
if not chosen:
return (
'<svg xmlns="http://www.w3.org/2000/svg" '
f'width="{_SVG_WIDTH}" height="{_SVG_HEIGHT}" '
f'viewBox="0 0 {_SVG_WIDTH} {_SVG_HEIGHT}">'
f'<rect width="{_SVG_WIDTH}" height="{_SVG_HEIGHT}" fill="#1b1f24"/>'
"</svg>"
)
weights = [t.weight for t in chosen]
min_weight, max_weight = min(weights), max(weights)
sized = [(t, _font_size(t.weight, min_weight, max_weight)) for t in chosen]
padding = 12.0
margin = 20.0
x = margin
y = margin
row_height = 0.0
texts: list[str] = []
for index, (term, font_size) in enumerate(sized):
width = _estimate_text_width(term.term, font_size)
if x + width + padding > _SVG_WIDTH - margin and x > margin:
x = margin
y += row_height + padding
row_height = 0.0
if y + font_size > _SVG_HEIGHT - margin:
# Ran out of vertical space; stop placing further (lower-weight) terms.
break
color = _PALETTE[index % len(_PALETTE)]
safe_term = (
term.term.replace("&", "&").replace("<", "<").replace(">", ">")
)
texts.append(
f'<text x="{x:.1f}" y="{(y + font_size):.1f}" '
f'font-family="sans-serif" font-size="{font_size:.1f}" '
f'fill="{color}">{safe_term}</text>'
)
x += width + padding
row_height = max(row_height, font_size)
body = "\n ".join(texts)
return (
'<svg xmlns="http://www.w3.org/2000/svg" '
f'width="{_SVG_WIDTH}" height="{_SVG_HEIGHT}" '
f'viewBox="0 0 {_SVG_WIDTH} {_SVG_HEIGHT}">\n'
f' <rect width="{_SVG_WIDTH}" height="{_SVG_HEIGHT}" fill="#1b1f24"/>\n'
f" {body}\n"
"</svg>"
)
# --- entity/graph matching ----------------------------------------------------
def _canonical_form(text: str) -> str:
"""Normalize an identifier/name to a canonical lowercase-alnum string."""
words: list[str] = []
for token in _TOKEN_RE.findall(text):
if is_identifier_token(token):
words.extend(split_identifier(token))
else:
words.append(token.lower())
return "".join(words)
def entity_name_candidates(entity_id: str, name_prop: str | None = None) -> set[str]:
"""Derive matchable name fragments from a structured entity id.
Real ids look like ``orchestrata-ultimate:spec:006-session-management`` or
``orchestrata-ultimate:code:pkg.module:ClassName:method`` — the whole id
never equals a term, so matching works on the meaningful fragments: each
colon segment past the project prefix, plus its hyphen/dot-separated words
and intact identifiers.
"""
candidates: set[str] = set()
if name_prop:
candidates.add(name_prop)
segments = entity_id.split(":")[2:] or [entity_id]
for seg in segments:
candidates.add(seg)
for part in re.split(r"[-./]", seg):
part = re.sub(r"^\d+$", "", part)
if len(part) >= 3:
candidates.add(part)
return candidates
def term_matches_entity_name(term: str, entity_name: str) -> bool:
"""True if ``term`` and ``entity_name`` refer to the same identifier,
modulo case and camelCase/snake_case formatting differences."""
if not term or not entity_name:
return False
if term.lower() == entity_name.lower():
return True
return _canonical_form(term) == _canonical_form(entity_name)
# --- output writers ------------------------------------------------------------
def write_json_report(
output_path: Path,
global_ranking: list[TermStat],
per_spec: dict[str, list[TermStat]],
top: int,
) -> None:
payload = {
"global": [t.to_dict() for t in global_ranking[:top]],
"per_spec": {
key: [t.to_dict() for t in stats[:30]] for key, stats in per_spec.items()
},
}
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def write_svg_report(output_path: Path, global_ranking: list[TermStat]) -> None:
svg = build_svg(global_ranking, max_terms=min(80, len(global_ranking)))
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(svg, encoding="utf-8")
# --- optional semantic-graph enrichment ---------------------------------------
async def apply_to_graph(global_ranking: list[TermStat]) -> None:
"""Best-effort: write a ``prominence`` property onto matching graph nodes."""
from orchestrata_ultimate.semantic_graph.adapters.memgraph import (
MemgraphSemanticGraph,
)
from orchestrata_ultimate.semantic_graph.config import SemanticGraphConfig
from orchestrata_ultimate.semantic_graph.repository import create_semantic_graph
config = SemanticGraphConfig.from_env()
graph = create_semantic_graph(config)
try:
connected = await graph.connect()
if not connected:
print(
"Semantic graph unreachable — skipping graph enrichment: "
"connect() returned False"
)
return
if not isinstance(graph, MemgraphSemanticGraph):
print(
"Semantic graph backend does not support enumeration — "
"skipping graph enrichment"
)
return
entities = await graph._all_entities() # noqa: SLF001
updates: list[dict[str, Any]] = []
for entity in entities:
best_weight: float | None = None
name_prop = entity.properties.get("name")
candidate_names = entity_name_candidates(
entity.id, name_prop if isinstance(name_prop, str) else None
)
for term_stat in global_ranking:
if any(
term_matches_entity_name(term_stat.term, name)
for name in candidate_names
):
if best_weight is None or term_stat.weight > best_weight:
best_weight = term_stat.weight
if best_weight is not None:
updates.append({"id": entity.id, "val": best_weight})
# One UNWIND write per batch — a per-entity round trip takes >12min
# at 10k entities; batching finishes in seconds.
batch_size = 1000
for start in range(0, len(updates), batch_size):
await graph._run_write( # noqa: SLF001
"UNWIND $rows AS row "
"MATCH (e:Entity {id: row.id}) SET e.prominence = row.val",
{"rows": updates[start : start + batch_size]},
)
print(
f"Matched {len(updates)} / {len(entities)} nodes; "
f"updated {len(updates)} nodes with prominence scores."
)
finally:
await graph.close()
# --- CLI -----------------------------------------------------------------------
def _build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Compute spec/requirements term prominence (TF-IDF word cloud)."
)
parser.add_argument("--top", type=int, default=200)
parser.add_argument(
"--output-json",
type=Path,
default=PROJECT_ROOT / "plans" / "spec_term_prominence.json",
)
parser.add_argument(
"--output-svg",
type=Path,
default=PROJECT_ROOT / "docs" / "diagrams" / "spec-word-cloud.svg",
)
parser.add_argument("--specs-dir", type=Path, default=PROJECT_ROOT / "specs")
parser.add_argument("--apply-to-graph", action="store_true", default=False)
return parser
def main(argv: list[str] | None = None) -> int:
args = _build_arg_parser().parse_args(argv)
documents = discover_documents(args.specs_dir)
global_ranking, per_spec = compute_tfidf(documents)
write_json_report(args.output_json, global_ranking, per_spec, args.top)
write_svg_report(args.output_svg, global_ranking)
print(f"Documents: {len(documents)}")
print(f"Wrote {args.output_json}")
print(f"Wrote {args.output_svg}")
print("Top 10 global terms:")
for stat in global_ranking[:10]:
print(f" {stat.term}: weight={stat.weight:.3f} count={stat.count}")
if args.apply_to_graph:
import asyncio
# Only the top-N terms define prominence; matching the FULL ranking
# (thousands of terms x 10k entities) runs for hours.
asyncio.run(apply_to_graph(global_ranking[: args.top]))
return 0
if __name__ == "__main__":
raise SystemExit(main())