
Telemetry Terminology Similarity
- 48 installs
- 62 repo stars
- Updated August 3, 2026
- terrylica/cc-skills
Helps with ai & agent building tasks.
About
telemetry-terminology-similarity is a Claude Code skill in the AI & Agent Building category.
- telemetry-terminology-similarity
- AI & Agent Building
- AI-coding skill
Telemetry Terminology Similarity by the numbers
- 48 all-time installs (skills.sh)
- Ranked #7,473 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/terrylica/cc-skills --skill telemetry-terminology-similarityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 48 |
|---|---|
| repo stars | ★ 62 |
| Last updated | August 3, 2026 |
| Repository | terrylica/cc-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
Telemetry Terminology Similarity
Score pairwise similarity of telemetry field names across three independent layers. Emits raw scores — no thresholds, no clustering, no opinions. The consuming AI agent applies its own domain judgment.
Self-Evolving Skill: This skill improves through use. If instructions are wrong, parameters drifted, or a workaround was needed — fix this file immediately, don't defer. Only update for real, reproducible issues.
When to Use This Skill
Use this skill when:
- Auditing a telemetry/logging schema for naming collisions
- Comparing two JSONL log schemas for field overlap
- Detecting
trace_idvstraceIdvsrequest_idvscorrelation_idstyle problems - Validating field naming consistency before shipping telemetry changes
Architecture
5-layer scoring pipeline — each layer catches what the others miss:
┌─────────────────────────────────────────────────────────┐
│ Layer 1: NORMALIZE │
│ camelCase/snake_case split + abbreviation expansion │
│ wordninja for concatenated words │
│ "traceId" → "trace id", "ts" → "timestamp" │
├─────────────────────────────────────────────────────────┤
│ Layer 2: SYNTACTIC (RapidFuzz, 0-100) │
│ token_set_ratio on normalized forms │
│ Catches: trace_id ↔ traceId, level ↔ log_level │
├─────────────────────────────────────────────────────────┤
│ Layer 3: TAXONOMIC (WordNet Wu-Palmer, 0.0-1.0) │
│ Head-noun synonym detection via hypernym tree │
│ Catches: level ↔ severity, error ↔ fault, op ↔ action │
├─────────────────────────────────────────────────────────┤
│ Layer 4: SEMANTIC (sentence-transformers, 0.0-1.0) │
│ Cosine similarity via all-MiniLM-L6-v2 embeddings │
│ Catches: error ↔ exception, user_id ↔ account_id │
├─────────────────────────────────────────────────────────┤
│ Layer 5: CANONICAL (--canonical flag, optional) │
│ RapidFuzz vs bundled OTel/OCSF/CloudEvents dictionary │
│ Catches: http_method → http.request.method (OTel) │
├─────────────────────────────────────────────────────────┤
│ Output: All pairs scored + canonical anchors. │
│ Agent decides what to act on — tool computes, judges. │
│ Use proposer-prompt.md for structured rename proposals.│
└─────────────────────────────────────────────────────────┘Two-Phase Workflow: Score → Propose
The skill works in two phases:
1. Phase 1 — Score (term_similarity.py): Compute raw similarity scores across 5 layers. Tool computes, no opinions emitted. 2. Phase 2 — Propose (references/proposer-prompt.md): A bundled prompt template that consumes the scoring JSON and asks the LLM to produce structured rename proposals with confidence levels, evidence citations, and explicit escape hatches.
The two phases are deliberately separated. Phase 1 is deterministic and reproducible; Phase 2 applies domain judgment that only an LLM with conversation context can provide.
Dependencies
All installed via uv run (PEP 723 inline metadata — no global install needed):
| Package | Purpose | Size |
|---|---|---|
sentence-transformers | Semantic embeddings (MiniLM-L6) | ~80 MB |
rapidfuzz | Fast fuzzy string matching (C++) | ~1.3 MB |
wordninja | Probabilistic word splitting | ~0.5 MB |
nltk | WordNet Wu-Palmer synonym detection | ~30 MB |
orjson | Fast JSON serialization | ~0.3 MB |
First run downloads the all-MiniLM-L6-v2 model (~80 MB) and WordNet data (~30 MB).
Script Location
The analysis script lives in this skill's references/ directory. Resolve the path before use:
# SSoT-OK: marketplace path resolution for cross-repo invocation
SCRIPT_DIR="$(dirname "$(find ~/.claude/plugins -path '*/telemetry-terminology-similarity/references/term_similarity.py' -print -quit 2>/dev/null)")"
SCRIPT="$SCRIPT_DIR/term_similarity.py"All examples below assume $SCRIPT is set. When invoking from the cc-skills repo itself, use the relative path directly.
Usage
Analyze field names directly
# SSoT-OK: uv run handles PEP 723 inline deps
uv run --python 3.14 "$SCRIPT" \
trace_id traceId request_id correlation_id \
level severity log_level priorityExtract fields from a Python codebase
Use Python regex extraction (macOS lacks grep -P):
python3 -c "
import re, glob
fields = set()
for f in glob.glob('**/*.py', recursive=True):
text = open(f).read()
for m in re.finditer(r'\"([a-z][a-z0-9_]*?)\":', text):
fields.add(m.group(1))
for f in sorted(fields):
print(f)
" | uv run --python 3.14 "$SCRIPT"Analyze from stdin (pipe from jq, etc.)
head -1 telemetry.jsonl | jq -r 'keys[]' | uv run --python 3.14 "$SCRIPT"Analyze a JSONL file's fields
uv run --python 3.14 "$SCRIPT" --jsonl /path/to/telemetry.jsonlCompare two JSON schemas
uv run --python 3.14 "$SCRIPT" --schema-a schema_v1.json --schema-b schema_v2.jsonControl output size
uv run --python 3.14 "$SCRIPT" --top 30 field1 field2 field3 # Top 30 pairs
uv run --python 3.14 "$SCRIPT" --top 0 field1 field2 field3 # All pairs
uv run --python 3.14 "$SCRIPT" --json field1 field2 field3 # JSON outputLookup against canonical standards (OTel/OCSF/CloudEvents)
# Anchor each field against 1,453 bundled canonical names from OTel + OCSF + CloudEvents
uv run --python 3.14 "$SCRIPT" --canonical http_method http_status request_id severityOutput adds a === CANONICAL ANCHORS === section showing the closest standard names per field. Useful for "should we rename to match an industry standard" decisions.
Generate structured rename proposals (Phase 2)
After running with --json --canonical, paste the output into `references/proposer-prompt.md` — a bundled prompt template that produces atomic, reviewable rename proposals with confidence levels and explicit escape hatches.
# Phase 1: Score
uv run --python 3.14 "$SCRIPT" --json --canonical [fields...] > analysis.json
# Phase 2: Apply proposer prompt (paste analysis.json into the template)
# The LLM produces structured proposals.json — review atomicallyParameters
| Parameter | Default | Description |
|---|---|---|
--top | 50 | Show top N pairs by combined score (0 = all) |
--canonical | false | Lookup each field against bundled OTel/OCSF/CloudEvents dict |
--jsonl | — | Extract fields from a JSONL file (all unique keys) |
--schema-a/-b | — | Cross-schema comparison (two JSON schema files) |
--json | false | Output as structured JSON instead of text |
Output Format
Text output (default)
Fields analyzed: 21
Unique after normalization: 19
=== EXACT DUPLICATES (after normalization) ===
trace_id == traceId
timestamp == ts
=== SCORED PAIRS (sorted by combined score) ===
syn tax sem comb pair
--- --- --- ---- ----
100.0 0.000 0.560 1.000 level <-> log_level
0.0 1.000 0.472 1.000 error <-> fault
66.7 0.909 0.457 0.909 operation <-> action
46.2 0.833 0.251 0.833 level <-> severity
28.6 0.667 0.700 0.700 error <-> exceptionThree independent scores per pair — the agent reads all three to decide:
- syn (syntactic): high = surface-level name variant
- tax (taxonomic): high = WordNet synonym (hypernym tree)
- sem (semantic): high = embedding similarity (distributional)
- comb (combined): max(syn/100, tax, sem) — sorting key
JSON output (--json)
Structured JSON with exact_duplicates and scored_pairs arrays.
How Each Layer Contributes
| Scenario | syn | tax | sem | Which layer wins |
|---|---|---|---|---|
trace_id vs traceId | 100.0 | 0.0 | 1.0 | Syntactic |
level vs severity | 46.2 | 0.833 | 0.251 | Taxonomic |
error vs fault | 0.0 | 1.000 | 0.472 | Taxonomic |
operation vs action | 66.7 | 0.909 | 0.457 | Taxonomic |
error vs exception | 28.6 | 0.667 | 0.700 | Semantic |
user_id vs account_id | 47.1 | 0.0 | 0.792 | Semantic |
Abbreviation Dictionary
The normalizer expands common telemetry abbreviations:
| Abbr | Expansion | Abbr | Expansion |
|---|---|---|---|
ts | timestamp | uid | user id |
req | request | resp | response |
err | error | msg | message |
svc | service | env | environment |
op | operation | lvl | level |
evt | event | ctx | context |
acct | account | cfg | configuration |
dur | duration | lat | latency |
Add domain-specific abbreviations by editing ABBREVIATIONS in term_similarity.py.
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
ModuleNotFoundError | Missing deps | Use uv run (PEP 723 resolves automatically) |
| Model download slow | First run | Cached after first download (~110 MB total) |
| Script not found from other repo | Path not resolved | Set $SCRIPT per Script Location section |
grep: invalid option -- P | macOS lacks PCRE | Use python3 -c "import re..." pattern instead |
Post-Execution Reflection
After this skill completes, check before closing:
1. Did the command succeed? — If not, fix the instruction or error table that caused the failure. 2. Did parameters or output change? — If the underlying tool's interface drifted, update Usage examples and Parameters table to match. 3. Was a workaround needed? — If you had to improvise (different flags, extra steps), update this SKILL.md so the next invocation doesn't need the same workaround.
Only update if the issue is real and reproducible — not speculative.
#!/usr/bin/env bash
# FILE-SIZE-OK
# Refetch canonical-names.json from upstream sources.
# Run when you want to update to newer OTel/OCSF/CloudEvents versions.
#
# Sources (Apache-2.0, redistributable):
# - OpenTelemetry semantic-conventions (v1.29.0)
# - OCSF schema dictionary (main branch)
# - CloudEvents spec formats (main branch)
set -euo pipefail
OTEL_VERSION="${OTEL_VERSION:-1.29.0}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="$(mktemp -d /tmp/canonical-build.XXXXXX)"
trap 'rm -rf "$WORK_DIR"' EXIT
cd "$WORK_DIR"
echo "→ Fetching CloudEvents schema..."
curl -sfL "https://raw.githubusercontent.com/cloudevents/spec/main/cloudevents/formats/cloudevents.json" -o cloudevents.json
echo " ✓ $(wc -c < cloudevents.json) bytes"
echo "→ Fetching OCSF dictionary..."
curl -sfL "https://raw.githubusercontent.com/ocsf/ocsf-schema/main/dictionary.json" -o ocsf.json
echo " ✓ $(wc -c < ocsf.json) bytes"
echo "→ Fetching OpenTelemetry semantic-conventions v${OTEL_VERSION}..."
curl -sfL "https://github.com/open-telemetry/semantic-conventions/archive/refs/tags/v${OTEL_VERSION}.tar.gz" -o otel.tar.gz
tar xzf otel.tar.gz
echo " ✓ $(find "semantic-conventions-${OTEL_VERSION}/model" -name 'registry.yaml' | wc -l | tr -d ' ') registry files"
echo "→ Building unified canonical-names.json..."
uv run --python 3.14 --with pyyaml python3 - <<PY
import json
from pathlib import Path
import yaml
OUT = []
otel_root = Path("semantic-conventions-${OTEL_VERSION}/model")
# OpenTelemetry
for reg in otel_root.rglob("registry.yaml"):
namespace = reg.parent.name
try:
data = yaml.safe_load(reg.read_text())
for group in data.get("groups", []):
for attr in group.get("attributes", []):
if "id" not in attr:
continue
OUT.append({
"name": attr["id"],
"source": "otel",
"namespace": namespace,
"brief": (attr.get("brief") or "").strip()[:200],
"stability": attr.get("stability", "unknown"),
})
except Exception as e:
print(f" ! {reg}: {e}")
# OCSF
ocsf_data = json.loads(Path("ocsf.json").read_text())
for name, info in ocsf_data.get("attributes", {}).items():
OUT.append({
"name": name,
"source": "ocsf",
"namespace": info.get("group", "unknown"),
"brief": (info.get("description") or "").strip()[:200],
"stability": "stable",
})
# CloudEvents
ce = json.loads(Path("cloudevents.json").read_text())
for name, info in ce.get("properties", {}).items():
OUT.append({
"name": name,
"source": "cloudevents",
"namespace": "envelope",
"brief": (info.get("description") or "").strip()[:200],
"stability": "stable",
})
# Dedupe + sort
seen = set()
unique = []
for entry in sorted(OUT, key=lambda x: (x["source"], x["name"])):
key = (entry["source"], entry["name"])
if key not in seen:
seen.add(key)
unique.append(entry)
out_path = Path("${SCRIPT_DIR}/canonical-names.json")
out_path.write_text(json.dumps(unique, indent=2))
print(f" ✓ {len(unique)} attributes → {out_path}")
print(f" ✓ {out_path.stat().st_size:,} bytes")
PY
echo
echo "✓ Done. Review the diff and commit canonical-names.json if it changed."
Canonical Telemetry Names Dictionary
Bundled reference of canonical attribute names from the four major FOSS observability standards. Used by term_similarity.py --canonical to anchor field names against established standards instead of just comparing them to each other.
Sources
| Source | License | Attributes | Purpose |
|---|---|---|---|
| OpenTelemetry | Apache-2.0 | 536 | Logs/traces/metrics canonical attrs |
| OCSF | Apache-2.0 | 907 | Security event taxonomy |
| CloudEvents | Apache-2.0 | 10 | Event envelope (CloudNative Computing) |
| Total | — | 1,453 | — |
Schema
Single JSON file canonical-names.json — array of objects:
{
"name": "http.request.method",
"source": "otel",
"namespace": "http",
"brief": "HTTP request method.",
"stability": "stable"
}| Field | Type | Description |
|---|---|---|
name | string | Canonical attribute name as it appears in the upstream spec |
source | string | One of otel, ocsf, cloudevents |
namespace | string | Logical grouping (e.g., http, db, system) |
brief | string | One-line description (max 200 chars) |
stability | string | stable / experimental / deprecated (OTel only) or stable (others) |
Refresh
Run build.sh to refetch from upstream and regenerate. The script pins specific upstream versions for reproducibility — bump them when you want newer attributes.
License Compatibility
All three sources are Apache-2.0, allowing redistribution as part of this skill (which inherits the cc-skills marketplace license). Attribution is preserved via the source field on every entry.
Why bundle 318 KB?
Three reasons:
1. Offline use — the skill works without network access 2. Reproducibility — pinned versions mean the same input always produces the same output 3. Speed — avoids 67 GitHub API calls for OTel registry files on every run
The dictionary updates rarely (OTel ships ~quarterly), so the cost of staleness is low.
Proposer Prompt Template
A copy-pasteable prompt that consumes the output of term_similarity.py --canonical --json and asks the LLM to produce structured, actionable rename proposals with confidence levels, evidence citations, and explicit escape hatches.
When to Use
After running term_similarity.py --json --canonical on a codebase, paste the JSON output into the template below. The LLM produces a proposals[] array where each entry is one atomic, revertable decision.
Design Principles
This template encodes 7 research-validated principles:
1. Reasoning before verdict (CoT improves calibration in 33/36 settings — arXiv 2505.14489) 2. Categorical confidence (HIGH/MEDIUM/LOW, not numerical — EMNLP 2023) 3. Score legend with anchored examples (prevents axis misalignment) 4. Disagreement → flag rule (multi-layer disagreement is structural honesty trigger) 5. Hard gates from domain context (co-occurrence, bounded context — overrides scores) 6. Explicit escape hatch (flag_for_review is high-status, not failure) 7. Atomic proposals with required edits[].file/line (vagueness is structurally impossible)
---
The Template
````markdown You are reviewing similarity scores for telemetry/log/schema field names in a codebase. Your job is to produce atomic, reviewable rename proposals — not vague suggestions.
Score Interpretation Legend
Three independent layers score each pair:
- syn (syntactic, 0-100): RapidFuzz
token_set_ratioon normalized field names. >85= near-identical strings (likely surface variant)60-85= shared prefix/suffix (related)<60= visually distinct strings
- tax (taxonomic, 0.0-1.0): WordNet Wu-Palmer head-noun similarity.
>0.85= WordNet synonyms (e.g., level↔severity, error↔fault)0.5-0.85= related concepts (siblings in hypernym tree)<0.5= unrelated branches
- sem (semantic, 0.0-1.0): sentence-transformers cosine similarity.
>0.75= embedding cousins (same context distribution)0.5-0.75= loosely related<0.5= different contexts
- comb (combined):
max(syn/100, tax, sem)— the strongest single signal wins.
Anchored Examples
| syn | tax | sem | pair | verdict | reason |
|---|---|---|---|---|---|
| 100 | 0.0 | 0.96 | user_id vs userId | RENAME | Pure case variant — surface drift only |
| 0 | 1.00 | 0.47 | error vs fault | RENAME | True synonyms via WordNet |
| 67 | 0.91 | 0.46 | operation vs action | RENAME | Synonyms + shared substring |
| 47 | 0.0 | 0.79 | user_id vs account_id | LEAVE_DISTINCT | High semantic, zero taxonomic = related entities, not duplicates |
| 88 | 0.30 | 0.40 | order_total vs order_status | FALSE_POSITIVE | String overlap only — completely different concepts |
| 56 | 0.0 | 0.54 | request_id vs trace_id | FLAG_FOR_REVIEW | Could be merged or could be intentionally distinct (OTel uses both) |
Canonical Anchor Interpretation
If canonical_anchors shows a high-score match in OTel/OCSF/CloudEvents, bias strongly toward renaming the local field to match the standard. The standard exists for ecosystem interop (OTel collectors, dashboards, vendor tools).
Example:
- Local field:
http_method(score 100 vs OTelhttp.request.method) - Proposal: rename to
http.request.method(the OTel canonical name)
Hard Gates (Override All Scores)
Two criteria veto any rename, regardless of how clean the scores look:
1. Co-occurrence gate: If both fields appear in the same log entry / span / row / record, they refer to distinct concepts and must NOT be merged. Their similarity is a naming-disambiguation problem, not a duplication problem.
2. Bounded context gate: If the two fields originate in different services, modules, or domain layers, leave them alone. Translate at the boundary instead. Forcing global consistency destroys local clarity.
You don't have direct access to verify these gates — but you SHOULD list them as blocking_unknowns for any high-confidence rename you propose, so a human can verify before applying.
Disagreement Rule (Mandatory)
If the three layers (syn, tax, sem) disagree by more than one band, the proposal kind MUST be flag_for_review. You may not propose a rename in this case. The disagreement itself is the signal.
Example: (syn=88, tax=0.30, sem=0.40) — syntactic says "similar", taxonomic says "unrelated", semantic says "unrelated". This is a string coincidence, not a real duplicate. Flag, don't rename.
Output Schema (REQUIRED)
Emit a single JSON object matching this schema:
{
"summary": {
"total_pairs_reviewed": <int>,
"rename_proposals": <int>,
"leave_distinct": <int>,
"false_positives": <int>,
"flagged_for_review": <int>
},
"proposals": [
{
"id": "<stable-slug, e.g. rename-trace-id-001>",
"kind": "rename | annotate | merge | split | flag_for_review | no_action",
"confidence": "HIGH | MEDIUM | LOW",
"field_a": "<name>",
"field_b": "<name>",
"evidence": {
"syn": <number>,
"tax": <number>,
"sem": <number>,
"agreement": "all_agree | partial | disagree",
"dominant_layer": "syntactic | taxonomic | semantic | none"
},
"canonical_match": {
"name": "<canonical name from OTel/OCSF/CloudEvents>",
"source": "otel | ocsf | cloudevents | none",
"score": <number>
},
"reasoning": "<2-3 sentences: which layer dominates, why, what real-world relationship would produce this score pattern>",
"proposed_action": "<one sentence: e.g., 'Rename trace_id → http.request.method to match OTel canonical'>",
"blocking_unknowns": [
"<things a human must verify before applying, e.g., 'Do these fields ever co-occur in the same span?'>"
]
}
]
}Quality Bar
A reviewer prefers 5 honest `flag_for_review` proposals over 1 confidently-wrong rename. If you cannot construct a plausible real-world story for a pair from name alone, return flag_for_review with the unknowns logged.
blocking_unknowns MUST be a non-empty array for any HIGH-confidence rename — you have no access to the actual files, so you must explicitly defer file/line enumeration to a follow-up step.
Process
1. PLAN: Read all scored pairs and canonical anchors. Build a mental model of which pairs cluster around which concepts. 2. CLASSIFY: For each pair with combined > 0.6, walk the rules above and classify into one of the 6 kind values. 3. CITE: Every proposal must reference the specific (syn, tax, sem) triple that justifies it. 4. VERIFY: Before emitting a proposal, ask: "Could two reasonable engineers disagree about this?" If yes, downgrade confidence by one level. 5. EMIT: Output the JSON object. No prose outside the JSON.
Input
[Paste the output of term_similarity.py --json --canonical below]
<INSERT ANALYSIS JSON HERE>````
---
Example Usage
# 1. Run the analysis
SCRIPT="$(find ~/.claude/plugins -path '*/telemetry-terminology-similarity/references/term_similarity.py' -print -quit)"
python3 -c "import re,glob; fields=set();
[fields.add(m.group(1)) for f in glob.glob('**/*.rs', recursive=True)
for m in re.finditer(r'^\s*(?:pub\s+)?([a-z][a-z0-9_]*)\s*:\s*[A-Z]', open(f).read(), re.MULTILINE)];
print('\n'.join(sorted(fields)))" | uv run --python 3.14 "$SCRIPT" --json --canonical > analysis.json
# 2. Apply the proposer prompt (paste analysis.json into the template above)
# The LLM produces a structured proposals.json
# 3. Review proposals atomically — accept/reject one at a timeWhy Not Just Ask "Find Duplicates"?
A naive prompt like "find duplicate field names" fails three ways:
1. Fabrication: LLMs invent specifics rather than say "I don't know" 2. Bundling: Multiple unrelated renames get crammed into one proposal 3. Anchoring bias: Earlier items contaminate later judgments
The structured template eliminates all three: required blocking_unknowns[] array forces honesty, atomic id per proposal prevents bundling, and the score legend + anchored examples calibrate the model upfront.
Two-Pass Variant (Higher Quality)
For higher-stakes audits, run the prompt twice:
Pass 1 — Drafter: Use the template above. Be generous; emit anything that _could_ warrant action.
Pass 2 — Critic: Take Pass 1's output, paste into:
You are reviewing a junior engineer's refactor proposals for a telemetry naming audit.
For each proposal, do one of:
- APPROVE: keep as-is
- DOWNGRADE_TO_FLAG: confidence too high for the evidence; convert to flag_for_review
- REJECT_WITH_REASON: the proposal is wrong (string coincidence, related-but-distinct concepts, or bundled concerns)
Reject if any of:
- evidence.agreement is "disagree" but kind is "rename"
- the proposal bundles multiple unrelated renames
- blocking_unknowns is empty (impossible — every proposal has unknowns from this analysis)
- the proposed_action contradicts the canonical_match
Output: same JSON schema as input, with "critic_verdict" added per proposal.Empirically eliminates 30-50% of low-quality proposals from Pass 1.
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.14"
# dependencies = [
# "sentence-transformers",
# "rapidfuzz",
# "wordninja",
# "orjson",
# "nltk",
# ]
# ///
# FILE-SIZE-OK
"""
Telemetry Terminology Similarity Scorer
Scores all pairwise field name similarities across three independent layers.
Emits raw scores sorted by combined strength — no thresholds, no clustering,
no opinions. The consuming AI agent applies its own domain judgment.
Layer 1 (Normalize): camelCase/snake_case split + wordninja + abbreviation expansion
Layer 2 (Syntactic): RapidFuzz token_set_ratio (0-100)
Layer 3 (Taxonomic): WordNet Wu-Palmer head-noun similarity (0.0-1.0)
Layer 4 (Semantic): sentence-transformers cosine similarity (0.0-1.0)
Usage:
# SSoT-OK: uv run handles PEP 723 inline deps
echo -e "trace_id\\ntraceId\\nrequest_id" | uv run --python 3.14 term_similarity.py
uv run --python 3.14 term_similarity.py --jsonl /path/to/telemetry.jsonl
uv run --python 3.14 term_similarity.py --top 30 field1 field2 field3
"""
from __future__ import annotations
import argparse
import re
import sys
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
import nltk
import orjson
import wordninja
from rapidfuzz import fuzz
from sentence_transformers import SentenceTransformer, util
if TYPE_CHECKING:
from nltk.corpus.reader.wordnet import WordNetCorpusReader
# ---------------------------------------------------------------------------
# WordNet lazy initialization
# ---------------------------------------------------------------------------
_WN: WordNetCorpusReader | None = None
def _get_wordnet() -> WordNetCorpusReader:
global _WN
if _WN is None:
nltk.download("wordnet", quiet=True)
nltk.download("omw-1.4", quiet=True)
from nltk.corpus import wordnet
_WN = wordnet
return _WN
# ---------------------------------------------------------------------------
# Layer 1: Normalization
# ---------------------------------------------------------------------------
ABBREVIATIONS: dict[str, str] = {
"ts": "timestamp", "uid": "user id", "acct": "account",
"req": "request", "resp": "response", "err": "error",
"msg": "message", "dur": "duration", "ms": "milliseconds",
"ns": "nanoseconds", "us": "microseconds", "svc": "service",
"env": "environment", "src": "source", "dst": "destination",
"ctx": "context", "op": "operation", "lvl": "level",
"evt": "event", "attr": "attribute", "idx": "index",
"cnt": "count", "num": "number", "desc": "description",
"cfg": "configuration", "auth": "authentication",
"conn": "connection", "lat": "latency",
}
def normalize_field_name(name: str) -> str:
"""Normalize a field name to space-separated lowercase tokens."""
tokens = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", name)
tokens = re.sub(r"[_\-./]", " ", tokens).lower().strip()
parts: list[str] = []
for token in tokens.split():
if token in ABBREVIATIONS:
parts.extend(ABBREVIATIONS[token].split())
else:
split = wordninja.split(token)
parts.extend(split if len(split) > 1 else [token])
return " ".join(parts)
# ---------------------------------------------------------------------------
# Layer 2: Syntactic (RapidFuzz)
# ---------------------------------------------------------------------------
def syntactic_similarity(a: str, b: str) -> float:
return fuzz.token_set_ratio(a, b)
# ---------------------------------------------------------------------------
# Layer 3: Taxonomic (WordNet Wu-Palmer, head nouns only)
# ---------------------------------------------------------------------------
_WN_CACHE: dict[tuple[str, str], float] = {}
def wordnet_similarity(a: str, b: str) -> float:
"""Wu-Palmer similarity between head nouns of two normalized names.
Memoized by (head_a, head_b) — at scale, repeated head nouns dominate
cost. For 840 fields, ~352k pair calls collapse to ~3k unique head pairs.
"""
head_a = a.split()[-1]
head_b = b.split()[-1]
if head_a == head_b:
return 0.0
# Canonical key (order-independent)
key = (head_a, head_b) if head_a < head_b else (head_b, head_a)
cached = _WN_CACHE.get(key)
if cached is not None:
return cached
wn = _get_wordnet()
best = 0.0
for s1 in wn.synsets(head_a):
for s2 in wn.synsets(head_b):
score = s1.wup_similarity(s2)
if score is not None and score > best:
best = score
_WN_CACHE[key] = best
return best
# ---------------------------------------------------------------------------
# Layer 4: Semantic (sentence-transformers)
# ---------------------------------------------------------------------------
_MODEL: SentenceTransformer | None = None
def get_model() -> SentenceTransformer:
global _MODEL
if _MODEL is None:
_MODEL = SentenceTransformer("all-MiniLM-L6-v2")
return _MODEL
def semantic_similarity_matrix(names: list[str]) -> list[list[float]]:
model = get_model()
embeddings = model.encode(names, show_progress_bar=False)
return util.cos_sim(embeddings, embeddings).tolist()
# ---------------------------------------------------------------------------
# Layer 5: Canonical Anchoring (bundled dictionary)
# ---------------------------------------------------------------------------
_CANONICAL: list[dict] | None = None
def _load_canonical() -> list[dict]:
"""Load bundled canonical-names.json from sibling directory."""
global _CANONICAL
if _CANONICAL is None:
dict_path = Path(__file__).parent / "canonical-dictionary" / "canonical-names.json"
if dict_path.exists():
_CANONICAL = orjson.loads(dict_path.read_text())
else:
_CANONICAL = []
return _CANONICAL
def canonical_match(field_normalized: str, top_n: int = 3) -> list[dict]:
"""Find closest canonical names for a normalized field name.
Returns list of {name, source, score} sorted by score desc.
Score is RapidFuzz token_set_ratio against the canonical name's normalized form.
"""
canonical = _load_canonical()
if not canonical:
return []
matches: list[tuple[float, dict]] = []
for entry in canonical:
canon_normalized = normalize_field_name(entry["name"])
score = fuzz.token_set_ratio(field_normalized, canon_normalized)
if score >= 60:
matches.append((score, entry))
matches.sort(key=lambda x: x[0], reverse=True)
return [
{
"name": entry["name"],
"source": entry["source"],
"namespace": entry.get("namespace", ""),
"score": round(score, 1),
}
for score, entry in matches[:top_n]
]
# ---------------------------------------------------------------------------
# Scoring Pipeline
# ---------------------------------------------------------------------------
@dataclass
class ScoredPair:
field_a: str
field_b: str
normalized_a: str
normalized_b: str
syntactic: float # 0-100
taxonomic: float # 0.0-1.0
semantic: float # 0.0-1.0
combined: float # weighted aggregate
@dataclass
class CanonicalAnchor:
field: str
normalized: str
matches: list[dict] # [{name, source, namespace, score}, ...]
@dataclass
class ScoringReport:
total_fields: int
unique_normalized: int
exact_duplicates: list[tuple[str, str]]
scored_pairs: list[ScoredPair]
canonical_anchors: list[CanonicalAnchor] = field(default_factory=list)
def to_json(self) -> str:
return orjson.dumps(
{
"total_fields": self.total_fields,
"unique_normalized": self.unique_normalized,
"exact_duplicates": self.exact_duplicates,
"scored_pairs": [
{
"field_a": p.field_a,
"field_b": p.field_b,
"normalized_a": p.normalized_a,
"normalized_b": p.normalized_b,
"syntactic": round(p.syntactic, 1),
"taxonomic": round(p.taxonomic, 3),
"semantic": round(p.semantic, 3),
"combined": round(p.combined, 3),
}
for p in self.scored_pairs
],
"canonical_anchors": [
{
"field": a.field,
"normalized": a.normalized,
"matches": a.matches,
}
for a in self.canonical_anchors
],
},
option=orjson.OPT_INDENT_2,
).decode()
def to_text(self) -> str:
lines: list[str] = []
lines.append(f"Fields analyzed: {self.total_fields}")
lines.append(f"Unique after normalization: {self.unique_normalized}")
lines.append("")
if self.exact_duplicates:
lines.append("=== EXACT DUPLICATES (after normalization) ===")
for a, b in self.exact_duplicates:
lines.append(f" {a} == {b}")
lines.append("")
if self.scored_pairs:
lines.append("=== SCORED PAIRS (sorted by combined score) ===")
lines.append(f" {'syn':>5s} {'tax':>5s} {'sem':>5s} {'comb':>5s} pair")
lines.append(f" {'---':>5s} {'---':>5s} {'---':>5s} {'----':>5s} ----")
for p in self.scored_pairs:
lines.append(
f" {p.syntactic:5.1f} {p.taxonomic:5.3f} {p.semantic:5.3f}"
f" {p.combined:5.3f} {p.field_a:25s} <-> {p.field_b}"
)
lines.append("")
if self.canonical_anchors:
lines.append("=== CANONICAL ANCHORS (top matches in OTel/OCSF/CloudEvents) ===")
for a in self.canonical_anchors:
if not a.matches:
continue
lines.append(f" {a.field}")
for m in a.matches:
lines.append(
f" {m['score']:5.1f} [{m['source']}] {m['name']}"
)
return "\n".join(lines)
def score_fields(
fields: list[str], *, top: int = 0, canonical: bool = False
) -> ScoringReport:
"""Score all field name pairs across 3 layers. No filtering, no thresholds.
Args:
fields: Input field names to score.
top: Limit output to top N pairs (0 = all).
canonical: If True, also lookup each field against the bundled
canonical dictionary (OTel/OCSF/CloudEvents).
"""
# Layer 1: Normalize
normalized = {f: normalize_field_name(f) for f in fields}
norm_to_originals: dict[str, list[str]] = defaultdict(list)
for orig, norm in normalized.items():
norm_to_originals[norm].append(orig)
exact_duplicates: list[tuple[str, str]] = []
for norm, originals in norm_to_originals.items():
if len(originals) > 1:
for i in range(1, len(originals)):
exact_duplicates.append((originals[0], originals[i]))
unique_fields = list(norm_to_originals.keys())
unique_originals = [norm_to_originals[n][0] for n in unique_fields]
# Layers 2 + 3 + 4: Score all pairs
n = len(unique_fields)
sem_matrix = semantic_similarity_matrix(unique_fields)
scored: list[ScoredPair] = []
for i in range(n):
for j in range(i + 1, n):
syn = syntactic_similarity(unique_fields[i], unique_fields[j])
wn = wordnet_similarity(unique_fields[i], unique_fields[j])
sem = sem_matrix[i][j]
# Combined: max of the three normalized scores.
# Each layer catches different things — the strongest signal wins.
combined = max(syn / 100.0, wn, sem)
# Skip pairs where no layer shows any signal
if combined < 0.25:
continue
scored.append(ScoredPair(
field_a=unique_originals[i],
field_b=unique_originals[j],
normalized_a=unique_fields[i],
normalized_b=unique_fields[j],
syntactic=syn,
taxonomic=wn,
semantic=sem,
combined=combined,
))
scored.sort(key=lambda p: p.combined, reverse=True)
if top > 0:
scored = scored[:top]
# Layer 5: Canonical anchoring (optional)
anchors: list[CanonicalAnchor] = []
if canonical:
for orig, norm in zip(unique_originals, unique_fields, strict=False):
matches = canonical_match(norm, top_n=3)
if matches:
anchors.append(CanonicalAnchor(
field=orig,
normalized=norm,
matches=matches,
))
return ScoringReport(
total_fields=len(fields),
unique_normalized=len(unique_fields),
exact_duplicates=exact_duplicates,
scored_pairs=scored,
canonical_anchors=anchors,
)
# ---------------------------------------------------------------------------
# Input helpers
# ---------------------------------------------------------------------------
def fields_from_jsonl(path: Path) -> list[str]:
all_fields: set[str] = set()
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = orjson.loads(line)
if isinstance(obj, dict):
_collect_keys(obj, "", all_fields)
except orjson.JSONDecodeError:
continue
return sorted(all_fields)
def fields_from_json_schema(path: Path) -> list[str]:
with open(path) as f:
schema = orjson.loads(f.read())
fields: set[str] = set()
_collect_schema_fields(schema, fields)
return sorted(fields)
def _collect_keys(obj: dict, prefix: str, keys: set[str]) -> None:
for k, v in obj.items():
full_key = f"{prefix}.{k}" if prefix else k
keys.add(full_key)
if isinstance(v, dict):
_collect_keys(v, full_key, keys)
def _collect_schema_fields(schema: dict, fields: set[str]) -> None:
if "properties" in schema:
for name, prop in schema["properties"].items():
fields.add(name)
if isinstance(prop, dict):
_collect_schema_fields(prop, fields)
if "items" in schema and isinstance(schema["items"], dict):
_collect_schema_fields(schema["items"], fields)
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Score telemetry field name similarity across 3 layers."
)
parser.add_argument("fields", nargs="*", help="Field names (also reads stdin)")
parser.add_argument("--jsonl", type=Path, help="Extract fields from JSONL file")
parser.add_argument("--schema-a", type=Path, help="First JSON schema")
parser.add_argument("--schema-b", type=Path, help="Second JSON schema")
parser.add_argument("--top", type=int, default=50, help="Show top N pairs (default: 50, 0=all)")
parser.add_argument("--canonical", action="store_true",
help="Lookup each field against bundled OTel/OCSF/CloudEvents dictionary")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
fields: list[str] = []
if args.jsonl:
fields.extend(fields_from_jsonl(args.jsonl))
elif args.schema_a and args.schema_b:
fields.extend(fields_from_json_schema(args.schema_a))
fields.extend(fields_from_json_schema(args.schema_b))
elif args.fields:
fields.extend(args.fields)
elif not sys.stdin.isatty():
for line in sys.stdin:
line = line.strip()
if line:
fields.append(line)
if not fields:
parser.print_help()
sys.exit(1)
seen: set[str] = set()
unique: list[str] = []
for f in fields:
if f not in seen:
seen.add(f)
unique.append(f)
report = score_fields(unique, top=args.top, canonical=args.canonical)
if args.json:
print(report.to_json())
else:
print(report.to_text())
if __name__ == "__main__":
main()