diff --git a/apps/desktop/src/features/score/ScoreView.test.tsx b/apps/desktop/src/features/score/ScoreView.test.tsx
index de4ccb95c..e733076ec 100644
--- a/apps/desktop/src/features/score/ScoreView.test.tsx
+++ b/apps/desktop/src/features/score/ScoreView.test.tsx
@@ -9,16 +9,16 @@ vi.mock("@tauri-apps/api/core", () => ({
}));
vi.mock("./ScoreViewer", () => ({
- ScoreViewer: ({ data, fileName }: { data: Uint8Array | null; fileName?: string }) => (
+ ScoreViewer: ({ scorePdfBytes, fileName }: { scorePdfBytes: Uint8Array | null; fileName?: string }) => (
- {data ? `bytes:${data.length}` : "no-data"}
+ {scorePdfBytes ? `bytes:${scorePdfBytes.length}` : "no-data"}
{fileName ? `:${fileName}` : ""}
)
}));
vi.mock("../../i18n", () => ({
- createTranslator: () => (key: string) =>
+ createTranslator: () => (translationKey: string) =>
({
scoreViewTitle: "Score",
scoreViewSubtitle: "Attach validated PDF scores to the current song.",
@@ -34,7 +34,7 @@ vi.mock("../../i18n", () => ({
scoreReadFailed: "Could not open the score PDF.",
scoreRemoveFailed: "Could not remove the score PDF.",
scoreRequiresProject: "Scores attach to the active analysis project."
- })[key] ?? key,
+ })[translationKey] ?? translationKey,
detectPreferredLocale: () => "en"
}));
@@ -413,4 +413,4 @@ describe("ScoreView", () => {
});
expect(screen.getByTestId("score-viewer")).toHaveTextContent("no-data");
});
-});
+});
\ No newline at end of file
diff --git a/apps/desktop/src/features/score/ScoreView.tsx b/apps/desktop/src/features/score/ScoreView.tsx
index 72732450f..146ba2da9 100644
--- a/apps/desktop/src/features/score/ScoreView.tsx
+++ b/apps/desktop/src/features/score/ScoreView.tsx
@@ -26,10 +26,15 @@ export interface ScoreViewProps {
* Extract the first line of a bridge error for display, falling back to the
* provided message when the error carries no usable text.
*/
-function bridgeErrorDetail(error: unknown, fallback: string): string {
- const raw = error instanceof Error ? error.message : typeof error === "string" ? error : null;
- const firstLine = raw?.split(/\r?\n/)[0]?.trim();
- return firstLine ? firstLine : fallback;
+function bridgeErrorDetail(bridgeError: unknown, fallbackMessage: string): string {
+ const rawErrorMessage =
+ bridgeError instanceof Error
+ ? bridgeError.message
+ : typeof bridgeError === "string"
+ ? bridgeError
+ : null;
+ const firstErrorLine = rawErrorMessage?.split(/\r?\n/)[0]?.trim();
+ return firstErrorLine ? firstErrorLine : fallbackMessage;
}
/**
@@ -38,13 +43,18 @@ function bridgeErrorDetail(error: unknown, fallback: string): string {
* embedded viewer, and removes attachments (metadata plus stored copy).
*/
export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
- const t = useMemo(() => createTranslator(detectPreferredLocale()), []);
- const attachments = useMemo(() => song.scoreAttachments ?? [], [song.scoreAttachments]);
- const [selected, setSelected] = useState(null);
+ const scoreTranslator = useMemo(() => createTranslator(detectPreferredLocale()), []);
+ const scoreAttachments = useMemo(
+ () => song.scoreAttachments ?? [],
+ [song.scoreAttachments]
+ );
+ const [selectedScoreAttachment, setSelectedScoreAttachment] = useState(
+ null
+ );
const [pdfBytes, setPdfBytes] = useState(null);
const [isAttaching, setIsAttaching] = useState(false);
const [isOpening, setIsOpening] = useState(false);
- const [error, setError] = useState(null);
+ const [scoreError, setScoreError] = useState(null);
const readRequestRef = useRef(0);
/**
@@ -52,22 +62,27 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
* the active project id explicitly; the storage controls are only wired up
* (and enabled) when a workspace is present, so this never runs without one.
*/
- const openAttachment = async (activeProjectId: string, attachment: ScoreAttachment) => {
+ const openAttachment = async (
+ activeProjectId: string,
+ scoreAttachment: ScoreAttachment
+ ) => {
const requestId = readRequestRef.current + 1;
readRequestRef.current = requestId;
- setSelected(attachment);
+ setSelectedScoreAttachment(scoreAttachment);
setPdfBytes(null);
- setError(null);
+ setScoreError(null);
setIsOpening(true);
try {
- const bytes = await readScorePdf(activeProjectId, attachment.id);
+ const scorePdfBytes = await readScorePdf(activeProjectId, scoreAttachment.id);
if (readRequestRef.current === requestId) {
- setPdfBytes(bytes);
+ setPdfBytes(scorePdfBytes);
}
} catch (readError) {
if (readRequestRef.current === requestId) {
- setSelected(null);
- setError(`${t("scoreReadFailed")} ${bridgeErrorDetail(readError, "")}`.trim());
+ setSelectedScoreAttachment(null);
+ setScoreError(
+ `${scoreTranslator("scoreReadFailed")} ${bridgeErrorDetail(readError, "")}`.trim()
+ );
}
} finally {
if (readRequestRef.current === requestId) {
@@ -82,56 +97,66 @@ export function ScoreView({ song, projectId, onSongUpdate }: ScoreViewProps) {
* started; the active project id is supplied by the enabled control.
*/
const handleAttach = async (activeProjectId: string) => {
- setError(null);
+ setScoreError(null);
setIsAttaching(true);
try {
- const result = await attachScorePdf(activeProjectId, song.id);
- const attachment: ScoreAttachment = { id: result.id, fileName: result.fileName };
- onSongUpdate({ ...song, scoreAttachments: [...attachments, attachment] });
+ const attachmentResult = await attachScorePdf(activeProjectId, song.id);
+ const scoreAttachment: ScoreAttachment = {
+ id: attachmentResult.id,
+ fileName: attachmentResult.fileName
+ };
+ onSongUpdate({ ...song, scoreAttachments: [...scoreAttachments, scoreAttachment] });
setIsAttaching(false);
- await openAttachment(activeProjectId, attachment);
+ await openAttachment(activeProjectId, scoreAttachment);
} catch (attachError) {
setIsAttaching(false);
- setError(bridgeErrorDetail(attachError, t("scoreAttachFailed")));
+ setScoreError(bridgeErrorDetail(attachError, scoreTranslator("scoreAttachFailed")));
}
};
/** Remove an attachment after confirmation (metadata and stored copy). */
- const handleRemove = async (activeProjectId: string, attachment: ScoreAttachment) => {
- const confirmed = window.confirm(
- t("scoreRemoveConfirm").replace("{fileName}", attachment.fileName)
+ const handleRemove = async (
+ activeProjectId: string,
+ scoreAttachment: ScoreAttachment
+ ) => {
+ const removalConfirmed = window.confirm(
+ scoreTranslator("scoreRemoveConfirm").replace("{fileName}", scoreAttachment.fileName)
);
- if (!confirmed) {
+ if (!removalConfirmed) {
return;
}
- setError(null);
+ setScoreError(null);
try {
- await removeScorePdf(activeProjectId, attachment.id);
+ await removeScorePdf(activeProjectId, scoreAttachment.id);
onSongUpdate({
...song,
- scoreAttachments: attachments.filter((entry) => entry.id !== attachment.id)
+ scoreAttachments: scoreAttachments.filter(
+ (scoreAttachmentEntry) => scoreAttachmentEntry.id !== scoreAttachment.id
+ )
});
- if (selected?.id === attachment.id) {
+ if (selectedScoreAttachment?.id === scoreAttachment.id) {
readRequestRef.current += 1;
- setSelected(null);
+ setSelectedScoreAttachment(null);
setPdfBytes(null);
setIsOpening(false);
}
} catch (removeError) {
- setError(bridgeErrorDetail(removeError, t("scoreRemoveFailed")));
+ setScoreError(bridgeErrorDetail(removeError, scoreTranslator("scoreRemoveFailed")));
}
};
return (
-
+
- {t("scoreViewTitle")} · {song.title}
+ {scoreTranslator("scoreViewTitle")} · {song.title}
-
{t("scoreViewSubtitle")}
+
+ {scoreTranslator("scoreViewSubtitle")}
+
{!projectId && (
- {t("scoreRequiresProject")}
+ {scoreTranslator("scoreRequiresProject")}
)}
- {error && (
+ {scoreError && (
- {error}
+ {scoreError}
)}
- {t("scoreListTitle")}
+ {scoreTranslator("scoreListTitle")}
- {attachments.length === 0 ? (
-
{t("scoreListEmpty")}
+ {scoreAttachments.length === 0 ? (
+
{scoreTranslator("scoreListEmpty")}
) : (
- {attachments.map((attachment) => (
+ {scoreAttachments.map((scoreAttachment) => (
-
- {t("scoreViewerFailedTitle")}
+
+ {scoreTranslator("scoreViewerFailedTitle")}
+
{errorMessage && (
{errorMessage}
)}
-
);
}
- const pageIndicator = t("scoreViewerPageIndicator")
+ const pageIndicator = scoreTranslator("scoreViewerPageIndicator")
.replace("{current}", String(pageNumber))
.replace("{total}", String(pageCount));
@@ -257,7 +263,7 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
variant="outline"
size="icon-lg"
className="size-12"
- aria-label={t("scoreViewerZoomOut")}
+ aria-label={scoreTranslator("scoreViewerZoomOut")}
onClick={zoomOut}
>
@@ -266,7 +272,7 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
variant="outline"
size="icon-lg"
className="size-12"
- aria-label={t("scoreViewerZoomIn")}
+ aria-label={scoreTranslator("scoreViewerZoomIn")}
onClick={zoomIn}
>
@@ -274,12 +280,12 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
- {t("scoreViewerFitWidth")}
+ {scoreTranslator("scoreViewerFitWidth")}
@@ -291,7 +297,7 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
variant="outline"
size="icon-lg"
className="size-14"
- aria-label={t("scoreViewerPrevPage")}
+ aria-label={scoreTranslator("scoreViewerPrevPage")}
disabled={pageNumber <= 1}
onClick={goToPreviousPage}
>
@@ -304,7 +310,7 @@ export function ScoreViewer({ data, fileName, onStatusChange }: ScoreViewerProps
variant="outline"
size="icon-lg"
className="size-14"
- aria-label={t("scoreViewerNextPage")}
+ aria-label={scoreTranslator("scoreViewerNextPage")}
disabled={pageNumber >= pageCount}
onClick={goToNextPage}
>
diff --git a/apps/desktop/src/features/score/pdfjs.test.ts b/apps/desktop/src/features/score/pdfjs.test.ts
index 8b494ca70..10a446665 100644
--- a/apps/desktop/src/features/score/pdfjs.test.ts
+++ b/apps/desktop/src/features/score/pdfjs.test.ts
@@ -26,29 +26,29 @@ describe("score PDF.js boundary", () => {
expect(GlobalWorkerOptions.workerSrc).toBe("/assets/pdf.worker.min.mjs");
});
- it("copies validated bytes through the hardened data-only API", () => {
- const source = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
+ it("translates semantic score PDF bytes to the vendor data field", () => {
+ const scorePdfBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
- loadScorePdf(source);
+ loadScorePdf(scorePdfBytes);
expect(getDocument).toHaveBeenCalledTimes(1);
- const parameters = vi.mocked(getDocument).mock.calls[0]?.[0];
- expect(parameters).toBeTypeOf("object");
- expect(Object.keys(parameters as object)).toEqual([
+ const pdfDocumentParameters = vi.mocked(getDocument).mock.calls[0]?.[0];
+ expect(pdfDocumentParameters).toBeTypeOf("object");
+ expect(Object.keys(pdfDocumentParameters as object)).toEqual([
"data",
"enableXfa",
"useWorkerFetch"
]);
- const hardenedParameters = parameters as {
+ const hardenedPdfParameters = pdfDocumentParameters as {
data: Uint8Array;
enableXfa: boolean;
useWorkerFetch: boolean;
};
- expect(hardenedParameters.data).toEqual(source);
- expect(hardenedParameters.data).not.toBe(source);
- source[0] = 0x00;
- expect(hardenedParameters.data[0]).toBe(0x25);
- expect(hardenedParameters.enableXfa).toBe(false);
- expect(hardenedParameters.useWorkerFetch).toBe(false);
+ expect(hardenedPdfParameters.data).toEqual(scorePdfBytes);
+ expect(hardenedPdfParameters.data).not.toBe(scorePdfBytes);
+ scorePdfBytes[0] = 0x00;
+ expect(hardenedPdfParameters.data[0]).toBe(0x25);
+ expect(hardenedPdfParameters.enableXfa).toBe(false);
+ expect(hardenedPdfParameters.useWorkerFetch).toBe(false);
});
});
\ No newline at end of file
diff --git a/apps/desktop/src/features/score/pdfjs.ts b/apps/desktop/src/features/score/pdfjs.ts
index ec622d42d..280d09946 100644
--- a/apps/desktop/src/features/score/pdfjs.ts
+++ b/apps/desktop/src/features/score/pdfjs.ts
@@ -31,10 +31,11 @@ export function configureScorePdfWorker(): void {
* to a no-op hook and unknown named entities are preserved literally rather
* than dereferenced, so no external-entity resolver is exposed by this API.
*/
-export function loadScorePdf(data: Uint8Array): PDFDocumentLoadingTask {
+export function loadScorePdf(scorePdfBytes: Uint8Array): PDFDocumentLoadingTask {
configureScorePdfWorker();
return getDocument({
- data: new Uint8Array(data),
+ // `data` is the pdf.js vendor contract; keep it at this adapter boundary.
+ data: new Uint8Array(scorePdfBytes),
enableXfa: false,
useWorkerFetch: false
});
diff --git a/services/analysis-engine/src/bandscope_analysis/temporal/stability.py b/services/analysis-engine/src/bandscope_analysis/temporal/stability.py
index 316b36602..a6863499f 100644
--- a/services/analysis-engine/src/bandscope_analysis/temporal/stability.py
+++ b/services/analysis-engine/src/bandscope_analysis/temporal/stability.py
@@ -94,25 +94,28 @@ def _safe_default() -> TempoStability:
}
-def _classify_stability(cv: float) -> Literal["steady", "loose", "variable"]:
+def _classify_stability(
+ bpm_variation_coefficient: float,
+) -> Literal["steady", "loose", "variable"]:
"""Map a coefficient of variation to a stability label.
Args:
- cv: Coefficient of variation of the local BPM series.
+ bpm_variation_coefficient: Coefficient of variation of the local BPM series.
Returns:
- "steady" if cv < 0.04, "loose" if cv < 0.10, else "variable".
+ "steady" if the coefficient is below 0.04, "loose" if it is below
+ 0.10, else "variable".
"""
- if cv < STEADY_CV_THRESHOLD:
+ if bpm_variation_coefficient < STEADY_CV_THRESHOLD:
return "steady"
- if cv < LOOSE_CV_THRESHOLD:
+ if bpm_variation_coefficient < LOOSE_CV_THRESHOLD:
return "loose"
return "variable"
def _summarize_run(
- run: list[tuple[int, float, float, float]],
- beats: NDArray[np.float64],
+ tempo_change_run: list[tuple[int, float, float, float]],
+ beat_times_array: NDArray[np.float64],
) -> TempoChange:
"""Collapse a run of adjacent flagged boundaries into one tempo change.
@@ -121,26 +124,31 @@ def _summarize_run(
chosen so the reported time sits at the center of the transition.
Args:
- run: Adjacent flagged boundaries as (index, deviation, before
- median BPM, after median BPM) tuples, in ascending index order.
- beats: Beat times in seconds, aligned with boundary indices.
+ tempo_change_run: Adjacent flagged boundaries as (index, deviation,
+ before median BPM, after median BPM) tuples, in ascending index
+ order.
+ beat_times_array: Beat times in seconds, aligned with boundary indices.
Returns:
The merged TempoChange with rounded outputs.
"""
- max_deviation = max(entry[1] for entry in run)
- ties = [entry for entry in run if entry[1] >= max_deviation - 1e-9]
- index, _, before_bpm, after_bpm = ties[len(ties) // 2]
+ max_deviation = max(flagged_boundary[1] for flagged_boundary in tempo_change_run)
+ tied_boundaries = [
+ flagged_boundary
+ for flagged_boundary in tempo_change_run
+ if flagged_boundary[1] >= max_deviation - 1e-9
+ ]
+ boundary_index, _, before_bpm, after_bpm = tied_boundaries[len(tied_boundaries) // 2]
return {
- "time": round(float(beats[index]), 3),
+ "time": round(float(beat_times_array[boundary_index]), 3),
"from_bpm": round(before_bpm, 1),
"to_bpm": round(after_bpm, 1),
}
def _detect_tempo_changes(
- bpms: NDArray[np.float64],
- beats: NDArray[np.float64],
+ local_bpm_values: NDArray[np.float64],
+ beat_times_array: NDArray[np.float64],
) -> list[TempoChange]:
"""Detect sustained tempo shifts in a local BPM series.
@@ -150,37 +158,65 @@ def _detect_tempo_changes(
merged into a single change.
Args:
- bpms: Per-beat local BPM series (one value per inter-beat
- interval); ``bpms[i]`` spans beats ``i`` to ``i + 1``.
- beats: Beat times in seconds; ``len(beats) == len(bpms) + 1``.
+ local_bpm_values: Per-beat local BPM series (one value per inter-beat
+ interval); each value spans one consecutive beat interval.
+ beat_times_array: Beat times in seconds; there is one more beat time
+ than local BPM value.
Returns:
Detected tempo changes in chronological order; empty if the track
is too short for a robust window comparison.
"""
- n_bpm = len(bpms)
- window = min(CHANGE_WINDOW_BEATS, n_bpm // 2)
- if window < MIN_CHANGE_WINDOW_BEATS:
+ bpm_value_count = len(local_bpm_values)
+ comparison_window_beats = min(CHANGE_WINDOW_BEATS, bpm_value_count // 2)
+ if comparison_window_beats < MIN_CHANGE_WINDOW_BEATS:
return []
- flagged: list[tuple[int, float, float, float]] = []
- for k in range(window, n_bpm - window + 1):
- before = float(np.median(bpms[k - window : k]))
- after = float(np.median(bpms[k : k + window]))
- deviation = abs(after - before) / before
- if deviation > CHANGE_RATIO_THRESHOLD:
- flagged.append((k, deviation, before, after))
-
- changes: list[TempoChange] = []
- run: list[tuple[int, float, float, float]] = []
- for entry in flagged:
- if run and entry[0] != run[-1][0] + 1:
- changes.append(_summarize_run(run, beats))
- run = []
- run.append(entry)
- if run:
- changes.append(_summarize_run(run, beats))
- return changes
+ flagged_boundaries: list[tuple[int, float, float, float]] = []
+ for boundary_index in range(
+ comparison_window_beats,
+ bpm_value_count - comparison_window_beats + 1,
+ ):
+ before_bpm_median = float(
+ np.median(
+ local_bpm_values[
+ boundary_index - comparison_window_beats : boundary_index
+ ]
+ )
+ )
+ after_bpm_median = float(
+ np.median(
+ local_bpm_values[
+ boundary_index : boundary_index + comparison_window_beats
+ ]
+ )
+ )
+ relative_bpm_deviation = (
+ abs(after_bpm_median - before_bpm_median) / before_bpm_median
+ )
+ if relative_bpm_deviation > CHANGE_RATIO_THRESHOLD:
+ flagged_boundaries.append(
+ (
+ boundary_index,
+ relative_bpm_deviation,
+ before_bpm_median,
+ after_bpm_median,
+ )
+ )
+
+ tempo_changes: list[TempoChange] = []
+ tempo_change_run: list[tuple[int, float, float, float]] = []
+ for flagged_boundary in flagged_boundaries:
+ if (
+ tempo_change_run
+ and flagged_boundary[0] != tempo_change_run[-1][0] + 1
+ ):
+ tempo_changes.append(_summarize_run(tempo_change_run, beat_times_array))
+ tempo_change_run = []
+ tempo_change_run.append(flagged_boundary)
+ if tempo_change_run:
+ tempo_changes.append(_summarize_run(tempo_change_run, beat_times_array))
+ return tempo_changes
def analyze_tempo_stability(
@@ -203,28 +239,28 @@ def analyze_tempo_stability(
beats, non-finite values, or non-increasing times) yields the
safe default instead of raising.
"""
- beats: NDArray[np.float64] = np.asarray(beat_times, dtype=np.float64)
- if beats.ndim != 1 or len(beats) < MIN_BEATS:
+ beat_times_array: NDArray[np.float64] = np.asarray(beat_times, dtype=np.float64)
+ if beat_times_array.ndim != 1 or len(beat_times_array) < MIN_BEATS:
return _safe_default()
- if not np.all(np.isfinite(beats)):
+ if not np.all(np.isfinite(beat_times_array)):
return _safe_default()
- intervals = np.diff(beats)
- if not np.all(intervals > 0.0):
+ beat_intervals = np.diff(beat_times_array)
+ if not np.all(beat_intervals > 0.0):
return _safe_default()
with np.errstate(divide="ignore", over="ignore"):
- bpms: NDArray[np.float64] = 60.0 / intervals
- if not np.all(np.isfinite(bpms)):
+ local_bpm_values: NDArray[np.float64] = 60.0 / beat_intervals
+ if not np.all(np.isfinite(local_bpm_values)):
return _safe_default()
- bpm_median = float(np.median(bpms))
- bpm_stdev = float(np.std(bpms))
- cv = bpm_stdev / bpm_median
+ bpm_median = float(np.median(local_bpm_values))
+ bpm_stdev = float(np.std(local_bpm_values))
+ bpm_variation_coefficient = bpm_stdev / bpm_median
return {
"bpm_median": round(bpm_median, 2),
"bpm_stdev": round(bpm_stdev, 2),
- "stability": _classify_stability(cv),
- "tempo_changes": _detect_tempo_changes(bpms, beats),
+ "stability": _classify_stability(bpm_variation_coefficient),
+ "tempo_changes": _detect_tempo_changes(local_bpm_values, beat_times_array),
}
diff --git a/services/analysis-engine/tests/test_temporal_naming_contract.py b/services/analysis-engine/tests/test_temporal_naming_contract.py
new file mode 100644
index 000000000..603f3882b
--- /dev/null
+++ b/services/analysis-engine/tests/test_temporal_naming_contract.py
@@ -0,0 +1,71 @@
+"""Naming-contract regression tests for temporal stability internals."""
+
+import ast
+from pathlib import Path
+
+
+TEMPORAL_STABILITY_MODULE = (
+ Path(__file__).resolve().parents[1]
+ / "src"
+ / "bandscope_analysis"
+ / "temporal"
+ / "stability.py"
+)
+
+LEGACY_UNDERSPECIFIED_IDENTIFIERS = frozenset(
+ {
+ "after",
+ "beats",
+ "before",
+ "bpms",
+ "changes",
+ "cv",
+ "deviation",
+ "entry",
+ "flagged",
+ "index",
+ "intervals",
+ "run",
+ "ties",
+ "window",
+ }
+)
+
+REQUIRED_SEMANTIC_IDENTIFIERS = frozenset(
+ {
+ "after_bpm_median",
+ "beat_intervals",
+ "beat_times_array",
+ "before_bpm_median",
+ "bpm_variation_coefficient",
+ "comparison_window_beats",
+ "flagged_boundaries",
+ "flagged_boundary",
+ "local_bpm_values",
+ "relative_bpm_deviation",
+ "tempo_change_run",
+ "tempo_changes",
+ "tied_boundaries",
+ }
+)
+
+
+def _module_identifiers(source_text: str) -> set[str]:
+ """Return organization-owned Python identifiers declared or referenced by the module."""
+ syntax_tree = ast.parse(source_text)
+ module_identifiers = {
+ node.id for node in ast.walk(syntax_tree) if isinstance(node, ast.Name)
+ }
+ module_identifiers.update(
+ node.arg for node in ast.walk(syntax_tree) if isinstance(node, ast.arg)
+ )
+ return module_identifiers
+
+
+def test_temporal_stability_internal_identifiers_are_semantically_specific() -> None:
+ """Require bounded-context names instead of generic one-word temporal identifiers."""
+ source_text = TEMPORAL_STABILITY_MODULE.read_text(encoding="utf-8")
+ module_identifiers = _module_identifiers(source_text)
+
+ assert not LEGACY_UNDERSPECIFIED_IDENTIFIERS.intersection(module_identifiers)
+ assert REQUIRED_SEMANTIC_IDENTIFIERS.issubset(module_identifiers)