Source code for standpoint

"""Standpoint: where do you stand?

Explainable 2D Principal Component Analysis (PCA) positioning map from any
comparison table: PCA finds the few directions along which the rows differ the
most, so a table of many criteria collapses into a readable two-axis map.

Turn a table of *approaches x criteria* (CSV or Markdown, numeric ratings on any
scale) into a competitive positioning map, plus a full dump of the coefficients:
a three-fold deliverable from one input file.

Pipeline
--------
1. parse   : CSV or Markdown table -> numeric DataFrame (blanks -> minimum value
             of the non-blank, non-NaN values in that column).
2. prepare : normalization (default = z-score standardization, i.e. correlation
             PCA, because PCA is scale-sensitive and criteria carry different
             variances). Missing cells are imputed with the column minimum.
3. pca_2d  : PCA onto 2 components, keeping the canonical axes (loadings) so
             every axis stays a readable linear combination of the criteria.
4. orient  : rigidly rotate the 2D scatter so the reference row (the first row by
             default) leads in the TOP-RIGHT, and reposition an all-max reference
             to the best Pareto point; RECOMPUTE the canonical axes in the rotated
             frame (new_components = R(alpha) @ components).

Then: automatic roles by principled projection, distinct OKLCH colours by map
position, local-LLM axis pole names from the loadings, and a de-cluttered,
hand-authored, interactive SVG figure (no Vega, no external chart-rendering
runtime). `export_all` writes PNG + SVG + a Markdown analysis + a YAML of
coordinates and coefficients.

Author
------
Warith Harchaoui, https://www.linkedin.com/in/warith-harchaoui
"""

from __future__ import annotations

__author__ = "Warith Harchaoui"
__url__ = "https://www.linkedin.com/in/warith-harchaoui"
__version__ = "0.13.2"

import contextlib
import functools
import logging
import math
import os
import re
import sys
import tempfile
from dataclasses import dataclass

import best_engine_ai_helper as beh
import numpy as np
import os_helper as osh
import pandas as pd
import yaml
from best_engine_ai_helper import llm
from langdetect import DetectorFactory
from langdetect import detect as _langdetect
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler

DetectorFactory.seed = 0  # deterministic language detection

# Library diagnostics go through logging, never bare print (a library must not
# grab stdout). The CLI in `run()` is the one place that prints on purpose.
logger = logging.getLogger("standpoint")

# "Good Colors" Apple-base palette: https://deraison.ai/colors/.
# The four highlighted roles keep a fixed identity hue; the axis cross and labels
# use neutrals. Every other dot is coloured by its map position (`gradient_colors`).
PALETTE = {
    "reference": "#FF3B30",  # Red    the reference leader (best), sits top-right
    "right": "#007AFF",  # Blue   challenger that most defines the right pole
    "worst": "#A52A2A",  # Brown  weakest overall, sits bottom-left
    "top": "#AF52DE",  # Purple challenger that most defines the top pole
    "competitor": "#8E8E93",  # Gray   placeholder; overridden by gradient_colors
    "axis": "#C7C7CC",  # light gray for the centred, dotted axis cross
    "label": "#1C1C1E",  # near-black label text
}
FONT = "Roboto, -apple-system, Helvetica, Arial, sans-serif"

# One local vision-LLM drives everything language-shaped: axis pole names, noun
# forms, and the ratings auto-fill. Standpoint deliberately runs that single
# vision model for the text tasks too, so every call goes through
# best-engine-ai-helper with kind="vlm". The figure's own self-check is NOT among
# them any more: `--check` calls `assess_layout`, which reads the geometry
# directly (`vlm_assess` remains available for a model's opinion, but nothing
# calls it by default).
#
# The model tag is NOT hard-coded here. It lives in the brief -> engine contract:
#   * llm.brief.yaml  is the INPUT (committed): a hardware-independent description
#     of the three-in-one job (structured JSON poles, bilingual prose, chart
#     reading) that best-engine-ai-helper ranks the local catalogue against.
#   * llm.engine.yaml is the OUTPUT (gitignored): the concrete backend + model the
#     resolver picked for THIS machine. `engine()` loads it, resolving from the
#     brief on first use and writing it. Runtime reads the model only from there.
_PKG_DIR = os.path.dirname(os.path.abspath(__file__))
_ENGINE: dict | None = None


[docs] def engine() -> dict: """Return the resolved LLM/VLM engine descriptor for Standpoint. Thin cache over :func:`best_engine_ai_helper.ensure`: loads ``standpoint/llm.engine.yaml`` (backend + per-kind model for this machine), or resolves it from the committed ``standpoint/llm.brief.yaml`` on first use and writes it. Raises loudly if the brief is missing. Nothing is computed at import time; the first LLM call triggers resolution. """ global _ENGINE if _ENGINE is None: _ENGINE = beh.ensure(_PKG_DIR) return _ENGINE
__all__ = [ "positioning", "Positioning", "parse_table", "analyze", "PCAResult", "assign_roles", "axis_poles", "gradient_colors", "to_svg", "render_figures", "png_on_white", "analysis_markdown", "analysis_prose", "check_analysis", "export_all", "suggest_ratings", "results_yaml", "validate_table", "resolve_polarity", "detect_language", "i18n", "assess_layout", "vlm_assess", "engine", "run", ] # --------------------------------------------------------------------------- # # 1. parse # --------------------------------------------------------------------------- # def _cell_to_number(cell: str) -> float: """Convert one table cell to a number (int or float); blanks -> NaN.""" cell = cell.replace("**", "").strip() if cell.lower() in {"", "-", "—", "?", "n/a", "na", "null", "none"}: return np.nan try: return float(cell.replace(",", ".")) except ValueError: return np.nan def _looks_like_markdown(text: str) -> bool: """True if any line starts with a pipe, i.e. the text is a Markdown table.""" return any(line.lstrip().startswith("|") for line in text.splitlines()) def _parse_markdown(text: str) -> pd.DataFrame: """Parse a GitHub-flavoured Markdown table into a numeric DataFrame. The first pipe-delimited row is the header (its first cell names the index); the separator row (only pipes/dashes/colons) is dropped, and every remaining cell is coerced to a number via `_cell_to_number`. """ rows = [ln.strip() for ln in text.splitlines() if ln.strip().startswith("|")] # A GitHub separator row is only pipes/dashes/colons/spaces. rows = [r for r in rows if not re.fullmatch(r"[|\s:\-]+", r)] def split(row: str) -> list[str]: """Split one table row into stripped cell strings, dropping edge pipes.""" return [c.strip() for c in row.strip().strip("|").split("|")] header = split(rows[0]) records, index = [], [] for row in rows[1:]: cells = split(row) name = cells[0].replace("**", "").strip() index.append(name) records.append([_cell_to_number(c) for c in cells[1:]]) frame = pd.DataFrame(records, index=index, columns=header[1:]) frame.index.name = header[0] # keep the first-column name (e.g. "Language") return frame
[docs] def parse_table(source: str) -> pd.DataFrame: """Parse a markdown/CSV table (path or raw string) into a numeric DataFrame. The first column becomes the row index (approach names); every other cell is parsed as a number (int or float); blanks become NaN. """ text = source is_path = "\n" not in source and len(source) < 4096 if is_path: try: with open(source, encoding="utf-8") as fh: text = fh.read() except (OSError, ValueError): text, is_path = source, False # not a real path -> treat as raw text if _looks_like_markdown(text): return _parse_markdown(text) df = pd.read_csv(source if is_path else pd.io.common.StringIO(text), index_col=0) return df.map(lambda c: _cell_to_number(str(c)))
# --------------------------------------------------------------------------- # # i18n: detect the table's language and localize the LLM prompts # --------------------------------------------------------------------------- # SUPPORTED_LANGS = ("en", "fr", "es") _I18N_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "locales", "i18n.yaml") #: The one content section of `locales/i18n.yaml`. Named `bundle` because that #: is how it is consumed: a caller asks for every string of one language at #: once, never for a single key. _I18N_SECTION = "bundle" @functools.lru_cache(maxsize=1) def _prompts() -> osh.Prompts: """The shared prompt loader, bound to this package's locale file.""" return osh.Prompts.from_path(_I18N_PATH, section=_I18N_SECTION)
[docs] def i18n(lang: str = "en") -> dict: """Prompt templates for `lang` (falls back to English), loaded from i18n.yaml. Delegates to `os_helper.Prompts`, the loader shared across the suite. This used to transpose the file by hand — key-then-locale on disk, locale-then-key for callers — behind a module-level cache, and resolve the language with a plain `.get(lang, …["en"])`. All three now live in os-helper, which also means `fr-CA` reaches the French prompts instead of silently getting English, and that a prompt whose translation dropped a `{placeholder}` is reported by `problems()` rather than raising at render time. Parameters ---------- lang : str Language code, regional tags included. Anything the file does not carry falls back to English. Returns ------- dict Every template for `lang`, keyed by semantic name. """ return _prompts().bundle(lang)
[docs] def detect_language(texts: list[str]) -> str: """Detect the language (one of SUPPORTED_LANGS) from text; default English. Used on the table's column names so the pole labels and written analysis come out in the table's own language. """ sample = " ".join(t for t in texts if t).strip() if not sample: return "en" try: lang = _langdetect(sample) except Exception: return "en" return lang if lang in SUPPORTED_LANGS else "en"
# --------------------------------------------------------------------------- # # 2. prepare (normalization / preprocessing) # --------------------------------------------------------------------------- #
[docs] def validate_table(df: pd.DataFrame) -> None: """Raise a clear ``ValueError`` if the table can't be positioned. Needs at least 2 options (rows) and 2 numeric criteria (columns) with some variation, no fully-empty column, and no duplicate: two options with identical ratings would land on the same point, and two identical criteria would count the same evidence twice and skew the axes. Otherwise PCA is undefined, degenerate, or misleading. """ if df.shape[0] < 2: raise ValueError(f"need at least 2 options (rows); got {df.shape[0]}.") if df.shape[1] < 2: raise ValueError(f"need at least 2 criteria (columns); got {df.shape[1]}.") all_nan = [c for c in df.columns if df[c].isna().all()] if all_nan: raise ValueError(f"criteria with no numeric values at all: {all_nan}.") constant = [c for c in df.columns if df[c].nunique(dropna=True) <= 1] if len(constant) == df.shape[1]: raise ValueError("every criterion is constant; nothing to position.") # Identical option rows coincide on the map; identical criterion columns are # redundant and pull the axes toward whatever they measure. Reject both so every # row and every column carries its own information. dup_rows = df.index[df.duplicated(keep=False)].unique().tolist() if dup_rows: raise ValueError( "options with identical ratings (they would sit on the same point): " f"{dup_rows}. Give each option at least one rating the others do not share." ) dup_cols = df.columns[df.T.duplicated(keep=False)].unique().tolist() if dup_cols: raise ValueError( "criteria with identical columns (they measure the same thing here): " f"{dup_cols}. Drop one, or vary its ratings so it adds information." )
def _resolve_reference(df: pd.DataFrame, reference: int | str) -> int: """Return the row index of the reference, with a helpful error if it's unknown.""" if isinstance(reference, str): if reference not in df.index: raise ValueError(f"reference {reference!r} is not one of the options.") return int(df.index.get_loc(reference)) if not -df.shape[0] <= reference < df.shape[0]: raise ValueError(f"reference index {reference} is out of range (0..{df.shape[0] - 1}).") return int(reference % df.shape[0]) def imputed_cells(df: pd.DataFrame) -> list[tuple[str, str]]: """Which ``(option, criterion)`` pairs :func:`impute` will have to invent. Returned so the deliverable can *say* which numbers were filled in. The placement this package produces is meant to be auditable, and a reader cannot audit a coordinate that was partly derived from a value the input never contained. """ mask = df.isna() return [ (str(option), str(criterion)) for option in df.index for criterion in df.columns if bool(mask.at[option, criterion]) ] def impute(df: pd.DataFrame) -> pd.DataFrame: """Fill missing cells with each column's minimum observed value. A blank criterion is treated as the worst (minimum) value for that criterion, rather than the mean; a missing rating should not flatter an approach. The choice is deliberate and stays. What matters is that it is **declared**: a filled cell is indistinguishable from a measured one once it reaches the map, and it moves more than the option it belongs to. Because the pipeline z-scores each criterion, an invented value shifts that column's mean and standard deviation, so *every* option's coordinates move with it. Measured on a five-option table with one blank cell: the affected option travelled 2.47 units on the vertical axis -- most of the map's height, and across the quadrant boundary, which is to say a different conclusion. Use :func:`imputed_cells` to report what was filled. """ return df.fillna(df.min(numeric_only=True)) # A header marker declaring a criterion as lower-is-better, e.g. "Price (↓)", # "Latency (lower)", "Errors (lower is better)". Stripped from the shown name. _LOWER_MARK = re.compile( r"\s*\(?\s*(↓|lower(?:\s+is\s+better)?|less\s+is\s+better)\s*\)?\s*$", re.I )
[docs] def resolve_polarity( df: pd.DataFrame, lower_is_better: list[str] | None = None ) -> tuple[pd.DataFrame, frozenset[str]]: """Detect lower-is-better criteria and return a clean-named copy + their names. A criterion is lower-is-better if its header carries a marker (``Price (↓)``, ``Latency (lower)``) or is named in `lower_is_better`. Markers are stripped from the column name; the returned set uses the cleaned names. """ explicit = {c.strip() for c in (lower_is_better or [])} rename, lower = {}, set() for col in df.columns: clean = _LOWER_MARK.sub("", str(col)).strip() if clean != col: # had a marker lower.add(clean) if clean in explicit or col in explicit: lower.add(clean) rename[col] = clean out = df.rename(columns=rename) return out, frozenset(lower & set(out.columns))
def prepare(df: pd.DataFrame) -> tuple[np.ndarray, list[str]]: """Impute missing cells (column minimum), then z-score standardize -> correlation PCA. Standardization (mean 0, sd 1 per criterion) is the right normalization here, always: PCA is scale-sensitive, and criteria live on different scales and units, so each must get an equal say. A criterion with a larger numeric spread would otherwise dominate the components purely because of its units. """ x = StandardScaler().fit_transform(impute(df).to_numpy(dtype=float)) return x, list(df.columns) # --------------------------------------------------------------------------- # # 3./4. PCA + orientation # --------------------------------------------------------------------------- # def _rotation(alpha: float) -> np.ndarray: """The 2x2 counter-clockwise rotation matrix for an angle `alpha` (radians).""" c, s = np.cos(alpha), np.sin(alpha) return np.array([[c, -s], [s, c]])
[docs] @dataclass class PCAResult: """The oriented 2D PCA of one comparison table: the map's raw geometry. Produced by :func:`analyze` and consumed by everything downstream (roles, pole naming, figure, YAML). It holds the two-component projection *after* the rotation that puts the reference option in the top-right, so ``scores`` are ready to plot. Attributes ---------- names : list[str] Option (row) labels, in input order. features : list[str] Criterion (column) names, in input order. scores : np.ndarray ``(n, 2)`` oriented coordinates, one row per option (axis-1, axis-2). components : np.ndarray ``(2, p)`` oriented canonical axes, i.e. the per-criterion loadings. explained_variance_ratio : np.ndarray Fraction of variance each of the two axes carries, from the PCA fit. rotation_deg : float The rotation (degrees) applied to bring the reference onto the +45° diagonal. reference : str Name of the option placed top-right. x_std : np.ndarray ``(n, p)`` normalized feature matrix that was fed to the PCA. lower : frozenset[str] Criteria where lower is better (their sign was flipped before the PCA). """ names: list[str] # row labels features: list[str] # attribute names scores: np.ndarray # (n, 2) oriented coordinates components: np.ndarray # (2, p) oriented canonical axes (loadings) explained_variance_ratio: np.ndarray # from the original PCA fit rotation_deg: float # alpha applied, in degrees reference: str # row placed top-right x_std: np.ndarray # (n, p) normalized feature matrix (PCA input) lower: frozenset[str] = frozenset() # criteria where lower is better (negated)
[docs] def loadings(self) -> pd.DataFrame: """Criterion weights per oriented axis, as a features x (axis-1, axis-2) frame.""" return pd.DataFrame(self.components.T, index=self.features, columns=["axis-1", "axis-2"])
[docs] def coords(self) -> pd.DataFrame: """Oriented (axis-1, axis-2) coordinates, one row per option.""" return pd.DataFrame(self.scores, index=self.names, columns=["axis-1", "axis-2"])
[docs] def analyze( df: pd.DataFrame, reference: int | str = 0, soften_reference: float = 1.0, lower_is_better: list[str] | None = None, ) -> PCAResult: """Run the full pipeline: prepare -> PCA(2) -> rotate reference to top-right. The reference row is rotated onto the +45 deg diagonal (equal, positive coordinates = top-right corner). The canonical axes are then recomputed in the rotated frame so their loadings describe the *displayed* axes. `soften_reference` repositions an all-max reference (a straight-5-stars first row otherwise lands as a far outlier) to the best **Pareto** point: max x and max y of the competitors, times this factor (default 1.0 = exactly best-in-class on each axis, so it weakly dominates everyone without being an outlier). Set to 0 or None to keep the raw PCA position. `lower_is_better` names criteria where a lower value is better (price, latency). They are negated before the PCA so the whole space is uniformly higher-is-better; header markers like ``Price (↓)`` are picked up automatically too. """ df, lower = resolve_polarity(df, lower_is_better) validate_table(df) ref_idx = _resolve_reference(df, reference) signed = df.copy() if lower: signed[list(lower)] = -signed[list(lower)] # flip so higher is better x, features = prepare(signed) pca = PCA(n_components=2) scores = pca.fit_transform(x) # (n, 2) in original PC frame components = pca.components_ # (2, p) rows = PC1, PC2 ref_vec = scores[ref_idx] phi = np.arctan2(ref_vec[1], ref_vec[0]) # current angle of the reference alpha = np.pi / 4 - phi # rotate it onto +45 deg r = _rotation(alpha) scores_rot = scores @ r.T # rotate every point components_rot = r @ components # recompute canonical axes if soften_reference: # Place the reference at the best *Pareto* point: just beyond best-in-class # on each axis, so it weakly dominates every competitor without being a far # outlier. Realistic leader, top-right, on the frontier. others = np.delete(scores_rot, ref_idx, axis=0) ideal_x = max(float(others[:, 0].max()), 0.0) * soften_reference ideal_y = max(float(others[:, 1].max()), 0.0) * soften_reference if ideal_x > 0 and ideal_y > 0: # A single competitor can define the frontier on both axes at once (the # lone point reaching furthest right AND furthest up); landing the # reference exactly there ties it pixel-for-pixel with that competitor # -- one dot, two labels fighting over it. Nudge it a hair further out # so it stays strictly "past" that competitor (matching the README's # promise), not merely tied with them. if np.any(np.all(np.isclose(others, [ideal_x, ideal_y]), axis=1)): ideal_x *= 1.05 ideal_y *= 1.05 scores_rot[ref_idx] = [ideal_x, ideal_y] # Centre the cloud on the origin (mid-range), so the axis cross sits in its # middle with equal margins on every side. Because the reference is the max on # both axes, this leaves it at the exact top-right corner. scores_rot = scores_rot - (scores_rot.max(axis=0) + scores_rot.min(axis=0)) / 2 return PCAResult( names=list(df.index), features=features, scores=scores_rot, components=components_rot, explained_variance_ratio=pca.explained_variance_ratio_, rotation_deg=float(np.degrees(alpha)), reference=str(df.index[ref_idx]), x_std=x, lower=lower, )
# --------------------------------------------------------------------------- # # roles (colour semantics) # --------------------------------------------------------------------------- # # Four highlighted roles, each a *domain-agnostic* pick from the map geometry # (see `assign_roles`): the leader, the weakest, and the two challengers that # reach furthest toward the top and right poles. Highest priority last (wins # ties): competitor < right < top < worst < best. ROLE_ORDER = ["competitor", "right", "top", "worst", "best"] ROLE_STYLE = { "best": {"color": PALETTE["reference"], "size": 170, "bold": True}, "worst": {"color": PALETTE["worst"], "size": 120, "bold": True}, "top": {"color": PALETTE["top"], "size": 120, "bold": True}, "right": {"color": PALETTE["right"], "size": 120, "bold": True}, "competitor": {"color": PALETTE["competitor"], "size": 70, "bold": False}, } def _rgb_to_hex(rgb: tuple[float, float, float]) -> str: """Convert an (r, g, b) triple in [0, 1] to a clamped ``#RRGGBB`` hex string.""" r, g, b = (max(0, min(255, round(c * 255))) for c in rgb) return f"#{r:02X}{g:02X}{b:02X}" def _oklab_to_hex(lightness: float, a: float, b: float) -> str: """Convert an OKLab colour (Ottosson 2020) to a clamped sRGB hex string.""" # Ottosson's fixed constants: OKLab -> LMS' (the matrix below), cube back to # cone responses (LMS), then LMS -> linear sRGB (the second matrix). These are # the published coefficients, not tuning knobs; do not hand-edit them. l_ = lightness + 0.3963377774 * a + 0.2158037573 * b m_ = lightness - 0.1055613458 * a - 0.0638541728 * b s_ = lightness - 0.0894841775 * a - 1.2914855480 * b lc, mc, sc = l_**3, m_**3, s_**3 # undo the cube-root that OKLab applies to LMS rgb_lin = ( +4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc, -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc, -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc, ) def gamma(u: float) -> float: """Apply the sRGB transfer function to one clamped linear channel.""" u = max(0.0, min(1.0, u)) return 1.055 * u ** (1 / 2.4) - 0.055 if u > 0.0031308 else 12.92 * u return _rgb_to_hex(tuple(gamma(c) for c in rgb_lin)) # Dot-colour tuning: competitors get vivid OKLCH hues spread EVENLY around the # circle (ordered by map direction) so hues are balanced: no muddy midtones, no # clumping toward pink, with a gentle per-name lightness spread for extra variety. _DOT_CHROMA = 0.125 _L_LO, _L_HI = 0.62, 0.82
[docs] def gradient_colors(result: PCAResult, roles: list[str]) -> list[str]: """Distinct, clean per-approach colours. Competitors are placed at EVENLY spaced hues around the OKLCH circle in order of their direction on the map: balanced hues, every colour vivid (fixed chroma, never a muddy centre), all distinct. Lightness gets a small per-name spread for extra separation. Named roles keep their fixed identity hue. """ scores = result.scores n = len(scores) comps = [i for i in range(n) if roles[i] == "competitor"] # Order competitors by map direction, then hand out evenly spaced hues. angles = np.arctan2(scores[:, 1], scores[:, 0]) ordered = sorted(comps, key=lambda i: float(angles[i])) m = max(1, len(ordered)) lightness_key = sorted(comps, key=lambda i: (sum(map(ord, result.names[i])), i)) l_of = { i: _L_LO + (_L_HI - _L_LO) * (rank / max(1, len(comps) - 1)) for rank, i in enumerate(lightness_key) } colors = [""] * n for rank, i in enumerate(ordered): hue = 2 * math.pi * (rank / m) # evenly spaced around the wheel colors[i] = _oklab_to_hex(l_of[i], _DOT_CHROMA * math.cos(hue), _DOT_CHROMA * math.sin(hue)) for i, role in enumerate(roles): if role != "competitor": colors[i] = ROLE_STYLE[role]["color"] return colors
def legend_order(scores: np.ndarray) -> list[int]: """Indices in reading order that matches the map, starting at the extreme top-right: banded rows top -> bottom, and within each row right -> left. """ n = len(scores) if n == 0: return [] # ~sqrt(n) horizontal bands so the legend reads like the map's rows (a squarish # grid), rather than one long column that ignores the vertical spread. bands = max(1, round(n**0.5)) per = math.ceil(n / bands) top_to_bottom = sorted(range(n), key=lambda i: -float(scores[i][1])) order: list[int] = [] for b in range(bands): row = top_to_bottom[b * per : (b + 1) * per] row.sort(key=lambda i: -float(scores[i][0])) # right -> left within the row order.extend(row) return order def corner_extremes(scores: np.ndarray) -> dict[str, int]: """Index of the most extreme point toward each corner (tr, tl, br, bl).""" sx, sy = scores[:, 0], scores[:, 1] return { "tr": int(np.argmax(sx + sy)), "tl": int(np.argmax(sy - sx)), "br": int(np.argmax(sx - sy)), "bl": int(np.argmax(-sx - sy)), } # Candidate label placements around a dot, as (dir_x, dir_y): right, left, up, down, # then the four diagonals: the first that doesn't collide wins. _LABEL_DIRS = [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (-1, 1), (1, -1), (-1, -1)] # Concentric rings tried in order: 0 hugs the dot, higher rings push the label one # extra row outward. A dot in a tight cluster whose near sides are all taken escapes # to a further ring instead of squeezing against a neighbour (or dropping its label). _LABEL_RINGS = (0.0, 1.0, 2.0, 3.0) def _overlaps(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> bool: """True if two axis-aligned boxes ``(x0, y0, x1, y1)`` intersect.""" return not (a[2] < b[0] or a[0] > b[2] or a[3] < b[1] or a[1] > b[3]) def label_placements( result: PCAResult, view_x: float, view_y: float, width_px: int = 900, height_px: int = 760, font_px: float = 11.0, keepin_x: float | None = None, keepin_y: float | None = None, ) -> dict[int, tuple[float, float, str]]: """Greedy de-clutter: choose which approaches to label and *where* to put each label. For every dot (corner extremes first, then outermost), try eight sides in priority order (right, left, up, down, then the four diagonals) across a few concentric rings, and take the first one that clears every dot and every label already placed, with a small breathing gap. First fit rather than best fit: every direction at a given ring already carries the same gap, so "first that clears" already is "closest" — and it means a label defaults to sitting right of its dot, the most legible side, only moving elsewhere when that side is actually blocked. A direction that would flip the label to the other side of an axis from its own dot (a dot just below the x-axis getting a label that reads above it) is skipped outright, so a label never appears to belong to the wrong quadrant. A cardinal placement is edge-anchored, not centred: right/left labels get `start`/`end` so their near edge — not their midpoint — sits the fixed gap away from the dot, which is what keeps that gap looking the same size for a short name and a long one (a centred "JavaScript" would otherwise read as farther from its dot than a centred "Go", even though both start the same distance away). Up/down and diagonal placements stay centred (`middle`), since there the label sits directly above/below/askew of the dot rather than beside it. Returns {index: (label_x, label_y, text_anchor)} for the labels that fit, `text_anchor` one of `"start"`/`"end"`/`"middle"` for the caller to render with. `view_x` / `view_y` are the half-extents of each axis's domain (they can differ), so the pixel-to-data conversion is correct even when the map is not square. `keepin_x` / `keepin_y`, when given, bound how far a label centre may stray from the origin, so labels stay inside the point cloud's band and never wander out into the outer margin where the pole words live. A candidate beyond the bound is skipped like any other blocked side. """ scores = result.scores sx = 2 * view_x / width_px # data units per pixel, x sy = 2 * view_y / height_px # data units per pixel, y # A breathing gap, in pixels: labels clear their neighbours by this much rather # than butting right up against them, which is what made tight clusters read as # glued. Kept tight (rather than a generous margin) so a label reads as *this* # dot's name, not a floating caption. Pixels, not data units, because the two # axes can have different data-per-pixel scales (a non-square view); doing the # direction geometry below in one shared pixel frame is what keeps the visual # gap the same size in x and y. dot_r_px, pad_px, row_px = 5.0, 3.0, 0.6 * font_px pad_x, pad_y = pad_px * sx, pad_px * sy dot_rx, dot_ry = dot_r_px * sx, dot_r_px * sy boxes = [(x - dot_rx, y - dot_ry, x + dot_rx, y + dot_ry) for x, y in scores] # Placement order matters: the four corner extremes go first (they anchor the # reading of the map), then the rest from the outermost inward. Whoever places # first gets the side it wants before the canvas fills up. A point near-tied # between two diagonals (e.g. an extreme x with a near-zero y) can win more than # one corner slot; dedupe by index (keeping first occurrence) so it is placed # once, not twice -- placing it twice made the second pass dodge its own # already-placed label as if it were a stranger's, stranding it far from its dot. corners = list(dict.fromkeys(corner_extremes(scores).values())) others = sorted( (i for i in range(len(result.names)) if i not in corners), key=lambda i: -float(np.hypot(*scores[i])), ) # Greedy: for each point walk the rings outward and, in the first ring with any # free side, keep the side closest to the dot. The clearance test pads the # candidate box on every edge, so a kept label keeps its gap from dots and from # labels already placed. Points with no free side anywhere carry no label rather # than overlap; the caller then falls back to the colour legend to name them. # A dot within this many pixels of an axis counts as "on" it for the # same-quadrant rule below: forcing an exact-zero comparison would let a dot # one pixel off the line still get flipped to the wrong side. axis_tol_px = 1.0 # R: the dot-to-label distance, a constant 2.5x the dot radius (per ring, one # extra `row_px` step further out). Eight fixed cases, one per direction in # `_LABEL_DIRS` (right, left, up, down, then the diagonals) -- each says where # the label's anchor point sits and which SVG `text-anchor` renders it there: # right anchor=start, (x+R, y) # left anchor=end, (x-R, y) # top anchor=middle, (x, y+R) # bottom anchor=middle, (x, y-R+ch) -- see below for the +ch # top-right anchor=start, (x+R, y+R) -- Euclidean R*sqrt(2) # top-left anchor=end, (x-R, y+R) # bottom-right anchor=start, (x+R, y-R-ch) # bottom-left anchor=end, (x-R, y-R-ch) # `ch` (one line-height) only touches the "bottom" cases, and with opposite # signs, because "top"/"bottom" are vertically CENTRED on their anchor point # (SVG has no "centre" baseline, so the renderer nudges the baseline down by # ~0.35*ch to fake it -- see `label_dy` in `to_svg`) while the diagonals are # baseline-anchored like left/right. Centring already puts a "top" label's # near (bottom) edge close to the dot with no correction needed; a "bottom" # label's near (top) edge is a full line-height above its baseline, so # pulling the baseline UP by `ch` (+ch) brings that near edge back down to R. # The bottom-diagonals are baseline-anchored already (no centring nudge), so # their near (top) edge sits BELOW the raw y-R point by that same line-height # -- pushing the baseline DOWN by `ch` (-ch) compensates the other way. ch_px = 1.3 * font_px placements: dict[int, tuple[float, float, str]] = {} def _dist_to_box(px: float, py: float, b: tuple[float, float, float, float]) -> float: """Distance from a point to a box (0 inside), in the shared pixel frame.""" return float(np.hypot(max(b[0] - px, 0.0, px - b[2]), max(b[1] - py, 0.0, py - b[3]))) centres_px = [(float(cx) / sx, float(cy) / sy) for cx, cy in scores] def _place( i: int, kx: float | None, ky: float | None ) -> tuple[tuple[float, float, float, float], tuple[float, float, str]] | None: """The first side, first ring, that fits for point `i` inside (kx, ky).""" x, y = scores[i] x_px, y_px = x / sx, y / sy # the dot's centre in the shared pixel frame w_px = len(result.names[i]) * 0.58 * font_px h_px = ch_px half_w_px, half_h_px = max(w_px / 2, 0.01), max(h_px / 2, 0.01) for k in _LABEL_RINGS: for ox, oy in _LABEL_DIRS: r_px = 2.5 * dot_r_px + k * row_px if oy == 0: # right / left anchor = "start" if ox > 0 else "end" lx_px = x_px + ox * r_px ly_px = y_px elif ox == 0 and oy > 0: # top anchor = "middle" lx_px = x_px ly_px = y_px + r_px elif ox == 0: # bottom anchor = "middle" lx_px = x_px ly_px = y_px - r_px + ch_px elif oy > 0: # top-right / top-left anchor = "start" if ox > 0 else "end" lx_px = x_px + ox * r_px ly_px = y_px + r_px else: # bottom-right / bottom-left anchor = "start" if ox > 0 else "end" lx_px = x_px + ox * r_px ly_px = y_px - r_px - ch_px box_px = ( (lx_px, ly_px - half_h_px, lx_px + w_px, ly_px + half_h_px) if anchor == "start" else (lx_px - w_px, ly_px - half_h_px, lx_px, ly_px + half_h_px) if anchor == "end" else ( lx_px - half_w_px, ly_px - half_h_px, lx_px + half_w_px, ly_px + half_h_px, ) ) # Never let a label cross to the other side of an axis from its own # dot (a dot just below the x-axis must not get a label that reads # above it): skip a direction that would flip the sign of either # coordinate, unless the dot itself already sits on that axis. if abs(x_px) > axis_tol_px and (lx_px >= 0) != (x_px >= 0): continue if abs(y_px) > axis_tol_px and (ly_px >= 0) != (y_px >= 0): continue # A purely vertical push keeps the label's x at the dot's own x; for # a dot that already sits near the vertical axis, that centres the # label ON the dashed line instead of beside it. Same for a purely # horizontal push straddling the horizontal axis. Only cardinals can # have this problem (a diagonal always moves off both lines at once). if ox == 0 and abs(x_px) < half_w_px: continue if oy == 0 and abs(y_px) < half_h_px: continue box = (box_px[0] * sx, box_px[1] * sy, box_px[2] * sx, box_px[3] * sy) # Keep labels out of the outer margin reserved for the pole words: # a label whose box edge crosses the bound is treated as blocked. if kx is not None and (box[0] < -kx or box[2] > kx): continue if ky is not None and (box[1] < -ky or box[3] > ky): continue probe = (box[0] - pad_x, box[1] - pad_y, box[2] + pad_x, box[3] + pad_y) if any(_overlaps(probe, b) for b in boxes): continue # Never clearly closer to another dot than to its own: a label # whose near edge sits beside a neighbour reads as the # neighbour's name. "Clearly" is under three quarters of its own # distance; an equal call is not a misreading. Measured on the # case that prompted it: 20 px to the neighbour, 44 to its own. own = _dist_to_box(x_px, y_px, box_px) if any( _dist_to_box(cx, cy, box_px) < 0.75 * own for j, (cx, cy) in enumerate(centres_px) if j != i ): continue # First fit, not best fit: _LABEL_DIRS is priority-ordered (right, # left, up, down, then diagonals), and every direction at a given # ring already carries the same gap, so the first one that clears # everything is exactly the preferred one. return (box, (float(lx_px * sx), float(ly_px * sy), anchor)) return None # Two passes. The first keeps labels inside the band (`keepin_*`). The # second gives the points it left unlabelled one more chance with the band # lifted, still inside the canvas. On a 2026-11-04 landscape the reference # dot, placed first in the top-right corner, had right, up and both top # diagonals outside the band, left and down blocked, and took the one side # left, which read as its neighbour's name; the rule against that now # refuses it, and this pass gives it the room on the right instead. for kx, ky in ((keepin_x, keepin_y), (view_x * 0.97, view_y * 0.97)): for i in corners + others: if i in placements: continue found = _place(i, kx, ky) if found is not None: boxes.append(found[0]) placements[i] = found[1] return placements def _axis_champion(axis_values: np.ndarray, exclude: set[int]) -> int: """Index of the option reaching furthest (largest value) along one axis. Parameters ---------- axis_values : np.ndarray One column of the oriented scores, e.g. every option's axis-1 coordinate. exclude : set[int] Row indices to skip (typically the leader, and an already-claimed champion), so the same option is never highlighted twice. Returns ------- int Row index of the highest not-excluded value along `axis_values`. """ order = np.argsort(axis_values)[::-1] # highest coordinate first return int(next(i for i in order if int(i) not in exclude))
[docs] def assign_roles( result: PCAResult, top: str | None = None, right: str | None = None, ) -> list[str]: """Label four options by domain-agnostic map geometry; the rest are competitors. Every pick is read straight off the oriented coordinates, so it means the same thing for any table (no per-domain keyword list): best the reference, sitting at the top-right corner by construction; worst the weakest overall: the minimum projection onto the top-right hero diagonal (equivalently the smallest axis-1 + axis-2); top the challenger reaching furthest up the vertical axis, the peer that most defines the map's *top* pole (the leader excluded); right the challenger reaching furthest along the horizontal axis, the peer that most defines the *right* pole (leader and top champion excluded). Parameters ---------- result : PCAResult The oriented positioning (`scores` and `reference`). top, right : str, optional Force a specific option into the top-pole / right-pole highlight by exact name, bypassing the geometric pick. Returns ------- list[str] One role per option, aligned with ``result.names``; collisions resolve by ``ROLE_ORDER`` (best beats worst beats the two champions). """ names = result.names scores = result.scores best_idx = names.index(result.reference) # Hero axis = the +45 deg diagonal after orientation; project onto (1, 1)/sqrt(2). hero_projection = scores @ (np.ones(2) / np.sqrt(2)) worst_idx = int(next(i for i in np.argsort(hero_projection) if i != best_idx)) # The leader is the max on both axes, so a champion is the *next* option out # along each axis: the challenger that best embodies that winning pole. top_idx = names.index(top) if top is not None else _axis_champion(scores[:, 1], {best_idx}) right_idx = ( names.index(right) if right is not None else _axis_champion(scores[:, 0], {best_idx, top_idx}) ) roles = ["competitor"] * len(names) for role in ROLE_ORDER[1:]: # skip "competitor" (default); low -> high priority idx = {"right": right_idx, "top": top_idx, "worst": worst_idx, "best": best_idx}[role] roles[idx] = role return roles
# --------------------------------------------------------------------------- # # axis naming (local LLM interprets the loading weights + column names) # --------------------------------------------------------------------------- # # Expand common acronyms to real words; never show acronyms in the figure. _ACRONYM_WORDS = { "tco": "Cost", "pii": "Privacy", "gdpr": "Compliance", "ux": "Experience", "fr": "French", "ev": "Vehicles", "ai": "Intelligence", "qa": "Quality", "stt": "Speech", "api": "Interface", "diy": "Homemade", } def _deacronym(label: str) -> str: """Expand or drop acronym tokens in a label so the figure shows real words.""" out = [] for tok in label.split(): if tok.isupper() and len(tok) <= 5: # looks like an acronym expanded = _ACRONYM_WORDS.get(tok.lower()) if expanded: out.append(expanded) # unknown acronym -> drop it else: out.append(tok) return " ".join(out).strip() def _one_word(feature: str) -> str: """A single real word from an attribute name: longest non-acronym token, expanding known acronyms so the figure never shows abbreviations. """ toks = re.findall(r"[^\W\d_]+", feature, re.UNICODE) # Unicode letters (keeps é, à, ç…) words = [t for t in toks if len(t) > 1 and not t.isupper()] # drop acronyms if words: return max(words, key=len).capitalize() for tok in toks: # only acronyms left if tok.lower() in _ACRONYM_WORDS: return _ACRONYM_WORDS[tok.lower()] return _ACRONYM_WORDS.get(feature.strip().lower(), feature.strip().capitalize()) # Small stop-words ignored when comparing labels for shared content words. _LABEL_STOP = { "and", "the", "for", "with", "your", "our", "per", "les", "des", "las", "los", "una", "por", "con", "sur", "del", } # A pole must be a positive quality; these markers signal a drawback (en/fr/es) and # get the label rejected, e.g. "High Cost", "Slow", "Expensive" never appear. _NEGATIVE_WORDS = { "high", "low", "expensive", "costly", "slow", "complex", "complicated", "poor", "weak", "insecure", "unreliable", "difficult", "limited", "hidden", "risky", "lack", "worse", "bad", "élevé", "eleve", "cher", "lent", "complexe", "coûteux", "couteux", "difficile", "faible", "alto", "caro", "lento", "complejo", "costoso", "débil", "debil", "riesgo", } def _content_words(label: str) -> set[str]: """Significant lowercase words in a label (>= 3 letters, minus stop-words).""" return { t for t in re.findall(r"[a-zA-Z]+", label.lower()) if len(t) >= 3 and t not in _LABEL_STOP } def _clean_label(label: str) -> str: """Expand acronyms, split camelCase, and keep at most three words.""" label = _deacronym(label) if label else "" label = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", label).strip() # split camelCase return " ".join(label.split()[:3]) def finalize_poles(raw: list[str], fallback: list[str]) -> list[str]: """Turn raw LLM pole labels into four clean, distinct, non-antonymous labels. Enforces: real words (no acronyms), at most three words, no label repeated, and no two labels sharing a content word, which rules out antonym pairs such as 'Cost Efficient' / 'High Cost'. A rejected label is replaced by its loading-derived fallback (drawn from a different criterion). """ def bad(w: str) -> bool: """True if label `w` must be rejected: empty, duplicate, shares a content word with an already-accepted label (rules out antonym pairs), or contains a negative word (a pole must name a positive quality). """ cw = _content_words(w) return ( not w or w.lower() in seen or bool(cw & used_words) or bool(cw & _NEGATIVE_WORDS) ) # never a drawback / negative out: list[str] = [] seen: set[str] = set() used_words: set[str] = set() for i, (label, fb) in enumerate(zip(raw, fallback, strict=False)): w = _clean_label(label) if bad(w): w = _clean_label(fb) # fall back to the loading word if bad(w): w = f"{w} {i}" seen.add(w.lower()) used_words |= _content_words(w) out.append(w) return out def _fallback_poles(components: np.ndarray, features: list[str]) -> list[str]: """Four distinct pole words [left, right, bottom, top] from the loadings. left/right = low/high end of axis-1; bottom/top = low/high end of axis-2. Each pole takes the most extreme not-yet-used attribute at that end. """ specs = [(0, 1), (0, -1), (1, 1), (1, -1)] # (axis, +1=ascending->low end first) used: set[str] = set() poles: list[str] = [] for axis, sign in specs: order = np.argsort(components[axis])[::sign] # sign +1 -> low end first word = next( (w for i in order if (w := _one_word(features[i])).lower() not in used), _one_word(features[order[0]]), ) used.add(word.lower()) poles.append(word) return poles def _poles_to_names(poles: list[str]) -> list[str]: """[left, right, bottom, top] -> ['left ↔ right', 'bottom ↔ top'].""" left, right, bottom, top = poles return [f"{left} ↔ {right}", f"{bottom} ↔ {top}"]
[docs] def axis_poles(result: PCAResult, model: str | None = None, lang: str | None = None) -> list[str]: """Four distinct pole labels [left, right, bottom, top] for the two axes. Each PCA axis is a weighted mix of the criteria. The local LLM names each pole (1-3 words) for what the approaches at that end are collectively strongest at, from the signed loadings and the original column names, in the table's own language (auto-detected from the column names; see `i18n.yaml`). Always uses the local model; loading-derived words only serve as the per-label robustness fallback when the model returns a bad label (see `finalize_poles`). """ feats = result.features fallback_poles = _fallback_poles(result.components, feats) if lang is None: lang = detect_language(feats) tpl = i18n(lang) try: # Every rating is higher-is-better, so a pole is best described by the # criteria approaches THERE score high on (its sign of the loading). def show(f: str) -> str: """Present a criterion to the model, flagging negated (lower-better) ones. A lower-is-better criterion was negated for the PCA, so a high score means a LOW raw value: show it as "low <name>" so the model names the benefit ("Affordable") rather than the drawback ("Expensive"). """ return f"low {f}" if f in result.lower else f def pole_strengths(k: int, sign: int) -> str: """Criteria (with weights) that define one end of axis `k`. `sign` selects the end: +1 for the positive-loading pole, -1 for the negative one. Returns them strongest-first as a human-readable string, or "—" when nothing loads meaningfully on that end. """ pairs = [ (f, w) for f, w in zip(feats, result.components[k], strict=False) if (w > 0) == (sign > 0) and abs(w) > 0.05 ] pairs.sort(key=lambda t: -abs(t[1])) return ", ".join(f"{show(f)} (weight {abs(w):.2f})" for f, w in pairs) or "—" # Glossary of any acronyms present in the columns, so the model translates # them instead of echoing them (built from the actual column names). present = { a.upper(): w for a, w in _ACRONYM_WORDS.items() if any(a.upper() in f.upper() for f in feats) } glossary = ( (tpl["glossary_prefix"] + "; ".join(f"{k} = {v}" for k, v in present.items()) + ".\n\n") if present else "" ) prompt = tpl["axis_prompt"].format( glossary=glossary, left=pole_strengths(0, -1), right=pole_strengths(0, +1), bottom=pole_strengths(1, -1), top=pole_strengths(1, +1), ) schema = { "type": "object", "properties": {k: {"type": "string"} for k in ("left", "right", "bottom", "top")}, "required": ["left", "right", "bottom", "top"], } # The schema-constrained call returns a parsed dict directly; the vision # model doubles as the text model here (kind="vlm"), per the brief. data = llm.chat( prompt, engine=engine(), kind="vlm", json_schema=schema, temperature=0, model=model, ) raw = [str(data.get(k, "")) for k in ("left", "right", "bottom", "top")] # Clean, de-duplicate, and reject antonym/shared-word pairs. return finalize_poles(raw, fallback_poles) except Exception: # backend unreachable / model absent / bad JSON logger.error("axis naming: LLM unavailable; the local model is required") raise
def noun_forms(word: str, model: str | None = None, lang: str | None = None) -> tuple[str, str]: """Singular and plural of `word` (the first-column name), in its own language. Used for the figure title and legend heading, so a table of "Language" reads "Languages in the Quadrant". The prompt lives in `i18n.yaml`. Always uses the local model; a naive `+s` plural only serves as the robustness fallback when the model is unreachable or returns a form that drifts from the column word. """ word = (word or "Approach").strip() or "Approach" if len(word) > 1 and word.lower().endswith("s"): # looks plural already naive = (word[:-1].capitalize(), word.capitalize()) else: naive = (word.capitalize(), word.capitalize() + "s") word_lang = detect_language([word]) if lang is None: lang = word_lang try: schema = { "type": "object", "properties": {"singular": {"type": "string"}, "plural": {"type": "string"}}, "required": ["singular", "plural"], } data = llm.chat( i18n(lang)["noun_prompt"].format(word=word), engine=engine(), kind="vlm", json_schema=schema, temperature=0, model=model, ) s = (str(data.get("singular") or "").strip() or naive[0]).capitalize() p = (str(data.get("plural") or "").strip() or naive[1]).capitalize() # Guard against the model swapping in a synonym (e.g. Voiture -> Véhicules): # a valid form must share a prefix with the actual column word. Only within # the word's own language, though -- a deliberate cross-language override # (e.g. the GUI's language toggle asking for French on an English "Language" # column) correctly returns "Langue", which shares no prefix with the # English original; the guard would wrongly discard it and leave the title # half-translated ("Programming languages dans le quadrant"). if lang == word_lang: prefix = word.lower()[: max(3, len(word) - 2)] if not s.lower().startswith(prefix): s = naive[0] if not p.lower().startswith(prefix): p = naive[1] return s, p except Exception: return naive # --------------------------------------------------------------------------- # # Hand-authored SVG # --------------------------------------------------------------------------- # def _esc(text: str) -> str: """Escape the three XML metacharacters for safe inclusion in the SVG markup.""" return str(text).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
[docs] def to_svg( result: PCAResult, roles: list[str] | None = None, poles: list[str] | None = None, colors: list[str] | None = None, noun_plural: str = "Approaches", title: str | None = None, attributes: pd.DataFrame | None = None, ) -> str: """Build a complete, self-contained, interactive SVG document for the map. Elements, bottom to top: a centred dashed cross through the origin (the neutral intersection), every approach coloured by its position (Apple-wheel OKLCH), the four pole words at the axis ends, and labels for the four corner extremes. No frame, spines, ticks, numeric scales, or arrows. Every dot carries a native ``<title>`` tooltip (the criterion values, or role + coordinates as a fallback) and a pure-CSS ``:hover``/``:focus`` lift — no JavaScript, no external renderer. The background is transparent; :func:`render_figures` derives an opaque-white companion for dark surfaces. `title` is the fully-localized figure title (e.g. "Voitures dans le quadrant"); when omitted it defaults to the English "<plural> in the Quadrant" so direct callers still get a sensible heading. """ ref = result.reference names = result.names if roles is None: roles = ["best" if n == ref else "competitor" for n in names] if poles is None: poles = _fallback_poles(result.components, result.features) left, right, bottom, top = poles if colors is None: colors = gradient_colors(result, roles) n = len(names) # Per-axis extents so each axis fills its own space: a low-variance axis (e.g. # PC2) is not squashed flat against the cross. Each axis gets its own domain. span_x = float(np.abs(result.scores[:, 0]).max()) or 1.0 span_y = float(np.abs(result.scores[:, 1]).max()) or 1.0 # Wide margin: the dots (the square hull reaches +/- span) occupy the central # ~half of the view, leaving a broad outer band on every side for the pole words, # so they read clearly outside the cloud rather than crowding the points. view_x, view_y = span_x * 2.0, span_y * 2.0 # Sizes adapt to the option count: bigger when few, smaller when many. def _scaled(lo: int, hi: int, few: int = 8, many: int = 40) -> int: """Interpolate a size between `hi` (at `few` options) and `lo` (at `many`). Keeps the map legible across table sizes: large glyphs on a sparse map, smaller ones once the plot gets crowded. Clamped outside ``[few, many]``. """ t = (min(max(n, few), many) - few) / (many - few) return round(hi + (lo - hi) * t) label_font = _scaled(11, 17) pole_font = _scaled(18, 26) # large: the poles anchor how the whole map reads legend_font = _scaled(9, 13) dot_size = _scaled(90, 240) # symbol AREA in px^2, matching the old point mark dot_r = math.sqrt(dot_size / math.pi) # Match the de-clutter geometry to the ACTUAL rendered canvas, so the # pixel->data conversion is right and labels sit close to their dots instead of # being pushed too far vertically. fig_w = 1200 fig_h = max(900, 26 * n + 160) # Labels may spill a little past the dot hull (+/- span) but must stay well inside # the outer margin the pole words own; this bound keeps them from wandering out # to the axis ends and colliding with a pole. keepin_x, keepin_y = span_x * 1.35, span_y * 1.35 placements = label_placements( result, view_x, view_y, width_px=fig_w, height_px=fig_h, font_px=label_font, keepin_x=keepin_x, keepin_y=keepin_y, ) # Colour scale follows the map: rows top -> bottom, left -> right within each row, # so if the legend is shown it reads in the same order the eye scans the plot. order = legend_order(result.scores) # A label is dropped only when the map is too crowded to place it without # overlapping another. In that case the colour legend earns its keep as the # fallback way to identify those dots. When every dot is labelled in place (the # common case) the legend would just repeat all N names, so it is omitted and the # plot keeps the whole canvas. all_labelled = len(placements) == len(names) # The hover tooltip lists the ORIGINAL criterion values (what the user typed), # one line per column, rather than the two abstract PC coordinates: that is what # a reader actually wants to compare. `attributes` is the raw options x criteria # table (index = option names); when absent we fall back to role + coordinates. attr_cols = [str(c) for c in attributes.columns] if attributes is not None else [] def _attr_value(nm: str, col: str) -> float | int | None: """One raw cell for `nm` on `col`, as a plain number (blanks -> None).""" v = attributes.loc[nm, col] if nm in attributes.index else None if v is None or pd.isna(v): return None f = float(v) return int(f) if f.is_integer() else f def tooltip_text(nm: str, r: str, x: float, y: float) -> str: """The native ``<title>`` body: one line per field, newline-separated.""" lines = [f"{noun_plural}: {nm}"] if attr_cols: for col in attr_cols: v = _attr_value(nm, col) if v is not None: lines.append(f"{col}: {v}") else: lines.append(f"role: {r}") lines.append(f"axis1: {x:.2f}") lines.append(f"axis2: {y:.2f}") return "\n".join(lines) # -- pixel mapping ------------------------------------------------------- # PAD = 12 # outer margin, matches the old Vega config.padding TITLE_Y = PAD + 14 # title baseline, independent of the band reserved above TITLE_BAND = 34 # vertical space reserved above the plot for the title LEGEND_W = 0 if all_labelled else max(160, legend_font * 11) plot_x0 = PAD plot_y0 = TITLE_BAND canvas_w = fig_w + 2 * PAD + LEGEND_W canvas_h = fig_h + TITLE_BAND + PAD sx = fig_w / (2 * view_x) sy = fig_h / (2 * view_y) def x_px(dx: float) -> float: """Map a data-space horizontal offset to an SVG pixel x coordinate. Parameters ---------- dx : float Horizontal offset from the plot centre, in data units. Returns ------- float Pixel x coordinate on the SVG canvas. """ return plot_x0 + fig_w / 2 + dx * sx def y_px(dy: float) -> float: """Map a data-space vertical offset to an SVG pixel y coordinate. Parameters ---------- dy : float Vertical offset from the plot centre, in data units. Positive is up in data space, so it is subtracted (SVG y grows downward). Returns ------- float Pixel y coordinate on the SVG canvas. """ return plot_y0 + fig_h / 2 - dy * sy # data y is up; SVG y is down if title is None: # direct callers get the English default; localized via i18n title = f"{noun_plural} in the Quadrant" parts: list[str] = [ f'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" ' f'width="{canvas_w:.0f}" height="{canvas_h:.0f}" viewBox="0 0 {canvas_w:.0f} {canvas_h:.0f}" ' f'role="img" aria-labelledby="sp-title sp-desc" font-family="{FONT}">', f'<title id="sp-title">{_esc(title)}</title>', f'<desc id="sp-desc">A 2D positioning map of {n} {_esc(noun_plural.lower())}, ' f"{_esc(left)} to {_esc(right)} on the horizontal axis, " f"{_esc(bottom)} to {_esc(top)} on the vertical axis.</desc>", "<style>" ".sp-dot{cursor:pointer}" ".sp-dot:hover circle,.sp-dot:focus circle{stroke-width:2.4}" ".sp-dot:hover,.sp-dot:focus{filter:brightness(1.08);outline:none}" "</style>", ] # -- pole words -------------------------------------------------------- # # Pole words sit far out in the margin, near the axis ends and well beyond the # dot hull (dots reach half the view), so they read as the map's headline rather # than crowding the points. Each hugs its own edge (left/right along the # horizontal, top/bottom centred on the vertical) and points outward. pole_x, pole_y = view_x * 0.92, view_y * 0.92 mid_dy = 0.35 * pole_font # vertical-centre baseline nudge (the "0.35em" trick) # -- dashed axis cross ----------------------------------------------------# # Stop each arm well short of its pole word's own footprint (word width for the # horizontal arm, whose text sits on the same y=0 baseline it would otherwise # cross through; font height for the vertical arm, whose text is horizontally # centred ON the line) so the dashes never run through a letter. `0.58*font_px` # is the same average-character-width estimate `label_placements` uses below. gap_px = 10 # breathing room between the line's tip and the word's nearest edge word_w_px = {w: len(w) * 0.58 * pole_font for w in (left, right, top, bottom)} right_edge = min(view_x * 0.98, pole_x - (word_w_px[right] + gap_px) / sx) left_edge = min(view_x * 0.98, pole_x - (word_w_px[left] + gap_px) / sx) top_edge = min(view_y * 0.98, pole_y - (pole_font + gap_px) / sy) bottom_edge = min(view_y * 0.98, pole_y - (pole_font + gap_px) / sy) parts.append( f'<line x1="{x_px(-left_edge):.1f}" y1="{y_px(0):.1f}" x2="{x_px(right_edge):.1f}" y2="{y_px(0):.1f}" ' f'stroke="{PALETTE["axis"]}" stroke-width="1.2" stroke-dasharray="2,4"/>' ) parts.append( f'<line x1="{x_px(0):.1f}" y1="{y_px(-bottom_edge):.1f}" x2="{x_px(0):.1f}" y2="{y_px(top_edge):.1f}" ' f'stroke="{PALETTE["axis"]}" stroke-width="1.2" stroke-dasharray="2,4"/>' ) def pole_label(dx: float, dy: float, text: str, anchor: str, y_nudge: float, which: str) -> str: """Render one axis pole word as an SVG ``<text>`` element. Parameters ---------- dx : float Horizontal offset from the plot centre, in data units. dy : float Vertical offset from the plot centre, in data units. text : str Pole word to display (e.g. the axis's "left" or "top" label). anchor : str SVG ``text-anchor`` value (``"start"``, ``"middle"`` or ``"end"``). y_nudge : float Extra pixel offset added to the baseline for vertical centring. which : str Pole identity (``"left"``, ``"right"``, ``"top"`` or ``"bottom"``), written to the ``data-pole`` attribute so the GUI can target this exact node when a pole is renamed client-side. Returns ------- str The ``<text>`` element markup for this pole word. """ # `data-pole` names this text node for the GUI: renaming a pole edits this # exact element's textContent client-side, no re-render round trip. return ( f'<text data-pole="{which}" x="{x_px(dx):.1f}" y="{y_px(dy) + y_nudge:.1f}" ' f'font-size="{pole_font}" font-style="italic" fill="#6E6E73" ' f'text-anchor="{anchor}">{_esc(text)}</text>' ) parts.append(pole_label(pole_x, 0.0, right, "end", mid_dy, "right")) parts.append(pole_label(-pole_x, 0.0, left, "start", mid_dy, "left")) parts.append( pole_label(0.0, pole_y, top, "middle", -0.21 * pole_font, "top") ) # "bottom" baseline parts.append( pole_label(0.0, -pole_y, bottom, "middle", 0.8 * pole_font, "bottom") ) # "top" baseline # -- dots (every approach coloured by position) ------------------------- # label_dy = 0.35 * label_font for i, ((x, y), nm, r, c) in enumerate(zip(result.scores, names, roles, colors, strict=False)): cx, cy = x_px(x), y_px(y) parts.append( f'<g class="sp-dot" tabindex="0" role="img" aria-label="{_esc(tooltip_text(nm, r, x, y))}">' f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{dot_r:.2f}" fill="{c}" ' f'stroke="white" stroke-width="1" opacity="0.95"/>' f"<title>{_esc(tooltip_text(nm, r, x, y))}</title>" f"</g>" ) if i in placements: lx, ly, anchor = placements[i] parts.append( f'<text x="{x_px(lx):.1f}" y="{y_px(ly) + label_dy:.1f}" font-size="{label_font}" ' f'fill="{PALETTE["label"]}" text-anchor="{anchor}">{_esc(nm)}</text>' ) # -- fallback legend, only when crowding dropped some in-place labels --- # if not all_labelled: leg_x = plot_x0 + fig_w + 24 leg_y = plot_y0 + 8 parts.append( f'<text x="{leg_x:.1f}" y="{leg_y:.1f}" font-size="{legend_font}" font-weight="700" ' f'fill="{PALETTE["label"]}">{_esc(noun_plural)}</text>' ) row_h = legend_font * 1.9 for row, i in enumerate(order): ry = leg_y + 20 + row * row_h parts.append( f'<circle cx="{leg_x + 6:.1f}" cy="{ry - legend_font * 0.35:.1f}" r="6" ' f'fill="{colors[i]}" opacity="0.95"/>' ) parts.append( f'<text x="{leg_x + 18:.1f}" y="{ry:.1f}" font-size="{legend_font}" ' f'fill="{PALETTE["label"]}">{_esc(names[i])}</text>' ) # -- title --------------------------------------------------------------- # parts.append( f'<text x="{plot_x0 + fig_w / 2:.1f}" y="{TITLE_Y}" font-size="18" font-weight="bold" ' f'fill="#000" text-anchor="middle">{_esc(title)}</text>' ) parts.append("</svg>") return "".join(parts)
# --------------------------------------------------------------------------- # # Export: figures (PNG + SVG), the written analysis (Markdown), YAML # --------------------------------------------------------------------------- # def _white_variant(svg: str) -> str: """Insert an opaque white background rect right after the opening ``<svg>`` tag. `svg` is transparent by default (see :func:`to_svg`); this derives the white-background companion by string surgery on the same markup rather than rebuilding the figure, so the two variants are guaranteed pixel-identical except for the backdrop. """ i = svg.index(">") + 1 m = re.match(r'<svg\b[^>]*\bwidth="([\d.]+)"[^>]*\bheight="([\d.]+)"', svg) w, h = (m.group(1), m.group(2)) if m else ("100%", "100%") return f'{svg[:i]}<rect width="{w}" height="{h}" fill="#FFFFFF"/>{svg[i:]}' def _svg_to_png(svg: str, scale: float = 2.0) -> bytes: """Rasterise an SVG string to PNG bytes via resvg_py, the house rasteriser. No browser, no Node, no Vega runtime: it renders exactly the static markup :func:`to_svg` emits. """ import resvg_py return resvg_py.svg_to_bytes(svg_string=svg, zoom=scale) def _atomic_write(path: str, data: str | bytes) -> None: """Write `data` to `path` atomically: no reader ever sees a partial write. Writes to a sibling temp file in the same directory (so the final `os.replace` is a same-filesystem rename, not a cross-filesystem copy) then renames it over the destination. A crash or a concurrent read mid-write sees either the old file (or nothing) or the fully-written new one, never a truncated deliverable. """ directory = os.path.dirname(path) or "." mode = "wb" if isinstance(data, bytes) else "w" encoding = None if isinstance(data, bytes) else "utf-8" fd, tmp_name = tempfile.mkstemp(dir=directory, prefix=f".{os.path.basename(path)}.") try: with os.fdopen(fd, mode, encoding=encoding) as fh: fh.write(data) os.replace(tmp_name, path) except BaseException: with contextlib.suppress(OSError): os.unlink(tmp_name) raise
[docs] def render_figures(svg: str, stem: str) -> list[str]: """Write an SVG figure (and its raster companion) as transparent and white pairs. `svg` is the transparent figure from :func:`to_svg`. Writes four files: the transparent `<stem>.png` / `<stem>.svg` (the default, for dropping onto any coloured page) and a white-background `<stem>.white.png` / `<stem>.white.svg` (for dark surfaces (e.g. GitHub dark mode) where the map's near-black labels would otherwise vanish on a transparent background). Returns the four paths in that order. """ written: list[str] = [] for suffix, variant in ((".", svg), (".white.", _white_variant(svg))): png_path, svg_path = f"{stem}{suffix}png", f"{stem}{suffix}svg" # Terminate the text file with a newline. `to_svg` ends at `</svg>`, # so without this every exported `.svg` is a no-final-newline file: # POSIX-incomplete, shown as `\ No newline at end of file` in every # diff that ever touches it, and a red `end-of-file-fixer` wherever a # consumer repo runs pre-commit on the figures it vendors. The PNG is # rasterised from `variant` itself, so it is unaffected. _atomic_write(svg_path, variant if variant.endswith("\n") else variant + "\n") _atomic_write(png_path, _svg_to_png(variant)) written += [png_path, svg_path] return written
[docs] def png_on_white(svg: str) -> bytes: """Render `svg` to PNG bytes on an opaque white background. The exported figures are transparent, but the vision self-check sends the image to a model whose backend flattens transparency onto a dark canvas, which would hide the near-black labels and make the check misfire. White is the figure's intended reading surface, so the check runs against a white-composited copy rather than the transparent file on disk. """ return _svg_to_png(_white_variant(svg))
[docs] def vlm_assess(image: str | bytes, model: str | None = None, lang: str | None = None) -> dict: """Ask the qwen vision-LLM to sanity-check a rendered positioning map. `image` is a PNG path or raw PNG bytes (bytes let the caller assess a white-composited render without touching the transparent file on disk). `lang` picks the prompt's language from `i18n.yaml` (falls back to English when None, like `i18n()` itself) -- there is no image text to auto-detect a language from, unlike `axis_poles`/`noun_forms`. Returns a verdict dict: whether the red leader dot sits top-right, whether the point labels are readable, and whether the four axis pole labels are visible, plus free-text notes (in `lang`). Empty dict if the model or a rendered image is unavailable. """ schema = { "type": "object", "properties": { "leader_top_right": {"type": "boolean"}, "readable": {"type": "boolean"}, "axis_labels_visible": {"type": "boolean"}, "notes": {"type": "string"}, }, "required": ["leader_top_right", "readable", "axis_labels_visible", "notes"], } prompt = i18n(lang or "en")["vlm_assess_prompt"] try: # `llm.chat` wants raw image bytes; read the file when handed a path. if isinstance(image, bytes): png_bytes = image else: with open(image, "rb") as fh: png_bytes = fh.read() verdict = llm.chat( prompt, engine=engine(), kind="vlm", images=[png_bytes], json_schema=schema, temperature=0, model=model, ) return verdict if isinstance(verdict, dict) else {} except Exception: return {}
[docs] def assess_layout(pos: Positioning, *, crowding: float = 0.045) -> dict: """Sanity-check a rendered positioning map from its own geometry, no model. Returns the same verdict shape `vlm_assess` does -- `leader_top_right`, `readable`, `axis_labels_visible`, `notes` -- computed directly from the coordinates and pole labels instead of asking a vision model to look at a PNG. This is what the CLI's ``--check`` runs. Measuring the teacher settled the question: across the 1480 recorded `vlm_assess` examples in this project's distillation corpus, `readable` came back `true` 1480 times and `axis_labels_visible` came back `true` 1480 times, while `leader_top_right` is a fact about a coordinate's sign. A model was being asked to squint at a picture to recover numbers the caller already holds exactly. Parameters ---------- pos : Positioning The computed map, as returned by `positioning()`. crowding : float, optional Two points closer than this fraction of the plot diagonal are reported as a label-overlap risk (default 0.045, i.e. under ~5% of the diagonal). Returns ------- dict ``{"leader_top_right": bool, "readable": bool, "axis_labels_visible": bool, "notes": str}``, the notes localized to ``pos.lang``. """ strings = i18n(pos.lang) coords = pos.coords leader = next((name for name, role in pos.role_of.items() if role == "best"), None) # The leader sits top-right when both oriented coordinates are positive -- # exactly the quadrant the vision prompt used to describe in prose. if leader is not None and leader in coords.index: x, y = (float(v) for v in coords.loc[leader].to_numpy()[:2]) leader_top_right = x > 0 and y > 0 else: leader_top_right = False poles_visible = len(pos.poles) == 4 and all(str(p).strip() for p in pos.poles) # Label legibility, as a geometric property: normalize each axis to its own # span so an axis with a wider range does not dominate the distance, then # flag pairs sitting on top of each other. Two labels drawn at effectively # the same spot is the one failure mode a reader actually meets. crowded_pairs = [] if len(coords) > 1: xy = coords.to_numpy()[:, :2].astype(float) spans = xy.max(axis=0) - xy.min(axis=0) spans[spans == 0] = 1.0 # a degenerate axis contributes no separation unit = (xy - xy.min(axis=0)) / spans names = list(coords.index) for a in range(len(names)): for b in range(a + 1, len(names)): if float(np.linalg.norm(unit[a] - unit[b])) < crowding: crowded_pairs.append(f"{names[a]} / {names[b]}") readable = not crowded_pairs notes = [] if not leader_top_right: notes.append(strings["check_leader_not_top_right"].format(leader=leader or "?")) if not poles_visible: notes.append(strings["check_poles_missing"]) if crowded_pairs: notes.append(strings["check_crowded"].format(pairs=", ".join(crowded_pairs))) return { "leader_top_right": leader_top_right, "readable": readable, "axis_labels_visible": poles_visible, "notes": " ".join(notes) if notes else strings["check_ok"], }
[docs] def suggest_ratings( noun: str, options: list[str], criteria: list[str], model: str | None = None, lang: str | None = None, ) -> dict[str, dict[str, int]]: """Ask the local model to fill a ratings matrix from the option / criterion names. This backs the GUI's "Flemme" (lazy) auto-fill: the user typed only the row (option) and column (criterion) names, and the model scores every option on every criterion on a 1 to 5 scale from its own knowledge. It is offline by design (the model's training knowledge, not a live web search), so nothing leaves the machine. Parameters ---------- noun : str The first-column word (e.g. "Programming Language"), naming what a row is. options : list[str] The option (row) names to score. criteria : list[str] The criterion (column) names to score each option on. model : str | None Force a specific model tag; ``None`` uses the one resolved in the engine. lang : str | None Output language for the prompt; detected from the names when `None`. Returns ------- dict[str, dict[str, int]] ``{option: {criterion: rating}}`` with every rating clamped to 1..5. Missing pairs default to 3 (neutral) so the caller always gets a complete matrix. Raises ------ RuntimeError The local model backend is unreachable, or the model errors. """ options = [o for o in (s.strip() for s in options) if o] criteria = [c for c in (s.strip() for s in criteria) if c] if not options or not criteria: raise ValueError("Name at least one option and one criterion before auto-filling.") if lang not in SUPPORTED_LANGS: lang = detect_language(options + criteria + [noun]) prompt = i18n(lang)["ratings_prompt"].format( noun=noun or "Option", options=", ".join(options), criteria=", ".join(criteria), ) # Constrain the model to the exact shape: every option maps to an object of its # criteria, each an integer. `json_schema` makes the backend return schema-valid JSON. schema = { "type": "object", "properties": { o: { "type": "object", "properties": {c: {"type": "integer"} for c in criteria}, "required": criteria, } for o in options }, "required": options, } data = llm.chat( prompt, engine=engine(), kind="vlm", json_schema=schema, temperature=0, model=model, ) # Clamp to 1..5 and backfill any gap with a neutral 3, so the grid is always full. out: dict[str, dict[str, int]] = {} for o in options: row = data.get(o, {}) if isinstance(data, dict) else {} out[o] = {c: _clamp_rating(row.get(c)) for c in criteria} return out
def _clamp_rating(value: object) -> int: """Coerce a model-returned score to an integer in 1..5; neutral 3 on anything odd.""" try: return max(1, min(5, int(round(float(value))))) # type: ignore[arg-type] except (TypeError, ValueError): return 3
[docs] def results_yaml( df: pd.DataFrame, result: PCAResult, roles: list[str], poles: list[str], axis_names: list[str], colors: list[str], ) -> str: """Everything about the fit as YAML: metadata, axis loadings, and per-approach coordinates, roles, colours, and original attribute values.""" evr = result.explained_variance_ratio left, right, bottom, top = poles feats = result.features filled = imputed_cells(df) raw = impute(df) doc = { "meta": { "reference": result.reference, "rotation_deg": round(result.rotation_deg, 3), "explained_variance_ratio": [round(float(v), 4) for v in evr], "cumulative_variance": round(float(evr.sum()), 4), "n_approaches": len(result.names), "attributes": feats, "lower_is_better": sorted(result.lower), # Which cells the input did not contain. Empty for a complete # table. Listed because every coordinate in this file was computed # partly from these, and a reader auditing a placement has to be # able to tell a measurement from a fill. "imputed_cells": [ {"option": option, "criterion": criterion} for option, criterion in imputed_cells(df) ], }, "axes": { "axis_1": { "name": axis_names[0], "pole_left": left, "pole_right": right, "loadings": { f: round(float(w), 4) for f, w in zip(feats, result.components[0], strict=False) }, }, "axis_2": { "name": axis_names[1], "pole_bottom": bottom, "pole_top": top, "loadings": { f: round(float(w), 4) for f, w in zip(feats, result.components[1], strict=False) }, }, }, "approaches": [ { "name": n, "coordinates": {"axis_1": round(float(x), 4), "axis_2": round(float(y), 4)}, "role": role, "color": color, "attributes": {f: round(float(raw.loc[n, f]), 3) for f in feats}, # Per option, so a reader looking at one row does not have to # cross-reference meta.imputed_cells to find out which of these # attributes the input actually supplied. "imputed_attributes": sorted(c for o, c in filled if o == n), } for n, (x, y), role, color in zip( result.names, result.scores, roles, colors, strict=False ) ], } return yaml.dump(doc, sort_keys=False, allow_unicode=True, width=100)
[docs] def analysis_prose( df: pd.DataFrame, result: PCAResult, poles: list[str], axis_names: list[str], *, lang: str = "en", model: str | None = None, ) -> str: """The local model's interpretation of the map's shape, as plain prose. Returns an empty string when the model is unreachable, so a caller can degrade to the computed facts rather than failing: the figure and the YAML are the deliverable's load-bearing halves, and neither needs a model. The prompt is given the axis names, the loadings and every option's position, and is told **not** to state a percentage, a coordinate or a rating. Those are computed in :func:`analysis_markdown` and printed around this prose. A model asked to narrate its own numbers will occasionally round one differently and contradict the file it sits in, and a positioning map that disagrees with itself is worse than one that says less. Parameters ---------- df : pandas.DataFrame The rated table, as parsed. result : PCAResult The oriented fit, for loadings and coordinates. poles : list of str ``[left, right, bottom, top]``. axis_names : list of str The two ``"A <-> B"`` axis names. lang : str, optional Prompt language; falls back to English. model : str, optional Override the resolved model tag. Returns ------- str Two or three paragraphs, or ``""`` when no model answered. """ feats = result.features coords = result.coords() def weights(k: int) -> str: """Criterion loadings on axis `k`, heaviest first, as a readable string.""" pairs = sorted(zip(feats, result.components[k], strict=False), key=lambda t: -abs(t[1])) return ", ".join(f"{f} {w:+.2f}" for f, w in pairs if abs(w) > 0.05) or "-" left, right, bottom, top = poles # The model is given its spatial facts in words, never as coordinates. # # The first version of this prompt passed "name (+2.47, +2.08)" per option # and named the poles, leaving the model to infer that a positive first # coordinate means the right-hand side. It never stated that convention, # and the model duly inverted the horizontal axis: it put the reference -- # pinned to the top-right corner by construction -- "on the left", and # called two left-hand options right-hand ones. The vertical axis, where # "up is more" is the stronger prior, came out correct. # # So the sides and quadrants are computed here. It is the same division of # labour as the percentages in `analysis_markdown`: Python owns every fact, # the model owns only the reading of it. Asking a language model to do # signed arithmetic and then trusting its prose is how an auditable # document acquires a confident falsehood. def side(axis: int, sign: int) -> str: """Option names on one end of `axis`, furthest out first. `sign` selects the end: +1 for the positive-coordinate side (right, or top), -1 for the other. Returns "-" when nothing sits there. """ picked = [ (name, float(row.iloc[axis])) for name, row in coords.iterrows() if (row.iloc[axis] > 0) == (sign > 0) and row.iloc[axis] != 0 ] picked.sort(key=lambda t: -abs(t[1])) return ", ".join(name for name, _ in picked) or "-" # Named with the direction word first, because the pole name alone was not # enough: told only "Amazon Lex: Adaptable + Resourceful", the model still # had to know that Adaptable is the left-hand pole, and it guessed wrong, # filing three left-hand options under "lower right". Spelling out # "left-bottom" leaves nothing to infer. words = i18n(lang)["analysis_md"] def quadrant(x: float, y: float) -> str: """One option's quadrant, direction word first then the two poles.""" horizontal = words["dir_right"] if x > 0 else words["dir_left"] vertical = words["dir_top"] if y > 0 else words["dir_bottom"] pole_h = right if x > 0 else left pole_v = top if y > 0 else bottom return f"{horizontal}-{vertical} ({pole_h} + {pole_v})" quadrants = "; ".join( f"{name}: {quadrant(float(row.iloc[0]), float(row.iloc[1]))}" for name, row in coords.iterrows() ) # The corners with their exact membership, so naming a group is copying # rather than assembling. Handing over per-option quadrants was not enough: # the model still wrote "the options in the bottom-right corner" and listed # one that was in the bottom-left. Third time the same remedy has worked -- # compute the thing and hand it over, rather than hand over its inputs. corners: dict[str, list[str]] = {} for name, row in coords.iterrows(): key = quadrant(float(row.iloc[0]), float(row.iloc[1])) corners.setdefault(key, []).append(str(name)) groups = " | ".join(f"{key}: {', '.join(members)}" for key, members in corners.items()) try: tpl = i18n(lang)["analysis_prompt"] prose = llm.chat( tpl.format( reference=result.reference, axis_1=axis_names[0], axis_2=axis_names[1], pole_left=left, pole_right=right, pole_bottom=bottom, pole_top=top, loadings_1=weights(0), loadings_2=weights(1), right_side=side(0, +1), left_side=side(0, -1), top_side=side(1, +1), bottom_side=side(1, -1), quadrants=quadrants, groups=groups, ), engine=engine(), kind="vlm", # Zero, like every other call in this package. Reproducibility is a # stated property here -- the same CSV must produce the same # deliverable -- and a sampled analysis would quietly make the # Markdown face the one file that changes between identical runs. temperature=0, model=model, ) text = prose if isinstance(prose, str) else str(prose) # The model is asked for prose and sometimes returns it fenced anyway. text = re.sub(r"^\s*```[a-z]*\s*|\s*```\s*$", "", text.strip()) return _house_style(text.strip(), lang) except Exception: logger.warning("written analysis: the local model was not reachable") return ""
def _house_style(text: str, lang: str) -> str: """Enforce the punctuation rules the prompt already asks for. The French prompt has said "never write a comma before et or ou" since it was written, and the model still does it a couple of times per analysis. An instruction is not a guarantee: the same reason `check_analysis` exists rather than trusting the prompt's "do not re-derive a position". This is a comma, so it can be fixed outright instead of merely reported -- removing one before a coordinating conjunction leaves the sentence saying exactly what it said. Driven by the locale's ``no_comma_before`` list, empty for languages where no such rule has been stated, so this is a no-op outside French. """ conjunctions = [ w.strip() for w in i18n(lang)["analysis_md"].get("no_comma_before", "").split(",") ] for word in [w for w in conjunctions if w]: text = re.sub(rf",(\s+{re.escape(word)}\b)", r"\1", text) return text
[docs] def check_analysis( prose: str, result: PCAResult, *, lang: str = "en", ) -> list[str]: """Find claims in the prose that the computed positions contradict. No model and no reasoning about meaning: find the direction words, find the option names, and compare each claim against the sign of that option's coordinate. A sentence saying an option sits on the right when its first coordinate is negative is a mismatch, full stop. This exists because the model does over-group even when handed every position in words. In one run it filed two left-hand options under "lower right" while placing three others correctly, and the only way to notice was to read the coordinates alongside the prose. A package whose promise is that a placement is *auditable* rather than hand-drawn has to be able to audit its own sentences. **Attribution is by position, not by sentence.** One sentence routinely carries two claims about two different groups -- "LiveKit shares the upper region, while Amazon Lex and Vapi are grouped in the lower right" -- so applying every direction word in a sentence to every name in it reported nine problems where two were real. A direction word therefore claims only the names since the previous direction word, and a direction word with no names of its own inherits the previous one's, which is what makes "lower-right" a single claim about a single group. A span that names nobody is ignored: "those on the right prioritise modularity" is a statement about the axis, not about anybody's position. **Claims stop at a contrastive conjunction**, not only at a full stop. "A clusters upper left, while B and C sit lower right" is two claims, and reading the sentence whole let the first one's forward window reach past `while` and claim B and C -- eight false reports on French prose whose every claim was correct. Splitting there also bounds the inheritance rule above to the compound directions it was written for, so a later aside ("but those in the left-top corner differ") no longer inherits the sentence's subject. The break words are translated like every other word list here, since the shape occurs in each language's prose. Parameters ---------- prose : str The written analysis, as returned by :func:`analysis_prose`. result : PCAResult The oriented fit the prose is describing. lang : str, optional Which language's direction vocabulary to read. Returns ------- list of str One message per contradicted claim, de-duplicated and sorted. Empty means every spatial statement in the prose agrees with the map. """ words = i18n(lang)["analysis_md"] opposite = {"left": "right", "right": "left", "top": "bottom", "bottom": "top"} def vocab(key: str) -> list[str]: """The comma-separated synonym list under `key`, lowercased.""" return [w.strip().lower() for w in words[key].split(",") if w.strip()] directions = { "left": (0, -1, vocab("dir_left_words")), "right": (0, +1, vocab("dir_right_words")), "bottom": (1, -1, vocab("dir_bottom_words")), "top": (1, +1, vocab("dir_top_words")), } coords = result.coords() problems: list[str] = [] # A contrastive conjunction ends a claim even mid-sentence, and attribution # must not reach across one. Splitting at sentence ends alone read "LiveKit # clusters in the upper left, while Rasa and Vocode sit in the lower right" -- # both halves true -- as the left-hand claim owning the right-hand names, # because the forward window runs to the next direction word and so swallowed # the names belonging to the following clause. The same break repairs the # inheritance rule: "ann-router sits upper right, but those in the left-top # corner differ" let a nameless span inherit ann-router, contradicting the # prose it was auditing. Resetting `carried` per clause is the point. breaks = sorted(vocab("clause_break_words"), key=len, reverse=True) alternatives = "|".join(re.escape(word) for word in breaks) clauses = re.compile(rf"\b(?:{alternatives})\b") for sentence in re.split(r"(?<=[.!?])\s+", prose): for clause in clauses.split(sentence): low = clause.lower() # Where every direction word and every option name falls, in order. hits: list[tuple[int, str]] = [] for label, (_axis, _sign, tokens) in directions.items(): for token in tokens: for match in re.finditer(rf"\b{re.escape(token)}\b", low): hits.append((match.start(), label)) names: list[tuple[int, object]] = [] for name in coords.index: at = low.find(str(name).lower()) while at != -1: names.append((at, name)) at = low.find(str(name).lower(), at + 1) if not hits or not names: continue hits.sort() # A compound direction is one claim, so the words making it up must # be attributed together: "en bas a gauche" and "lower-right" name # a corner, not two sides. A run of direction words with no option # name between consecutive members is therefore one group, and a # name appearing between two of them closes the group -- which is # exactly what separates two coordinated claims ("Chroma sits in # the lower left, and ann-router in the upper right"). Reading the # words one at a time let the first corner's forward window claim # the second corner's subject and contradicted correct prose. groups: list[list[tuple[int, str]]] = [] for position, label in hits: adjacent = groups and not any(groups[-1][-1][0] < at < position for at, _ in names) if adjacent: groups[-1].append((position, label)) else: groups.append([(position, label)]) carried: list[object] = [] for index, group in enumerate(groups): start, end = group[0][0], group[-1][0] previous = groups[index - 1][-1][0] if index else 0 following = groups[index + 1][0][0] if index + 1 < len(groups) else len(low) # Both word orders occur and both have to be read. "Vapi sits on # the right" puts the name before the direction; "in the bottom-right # corner are Vapi and Rasa" puts it after, and a rule that only # looked backwards silently passed the second -- which is how a # wrong grouping survived a run this checker reported clean. before = [n for at, n in names if previous <= at < start] after = [n for at, n in names if end < at <= following] claimed = before or after if claimed: carried = claimed for _position, label in group: axis, sign, _tokens = directions[label] for name in carried: value = float(coords.loc[name].iloc[axis]) if value == 0 or (value > 0) == (sign > 0): continue problems.append( words["mismatch"].format( option=name, claimed=label, actual=opposite[label] ) ) return sorted(set(problems))
[docs] def analysis_markdown( df: pd.DataFrame, result: PCAResult, poles: list[str], axis_names: list[str], *, lang: str = "en", title: str | None = None, model: str | None = None, ) -> str: """The written analysis as a Markdown document: computed facts, then prose. The third face of the deliverable, next to the figure and the YAML. The module docstring has promised it for a long time and :func:`export_all` stopped writing it, which left every consumer parsing a file that was never there. The split is the point. Everything numeric here -- the variance each axis preserves, the cumulative total, which cells were imputed -- is computed in Python and written by this function. The model contributes interpretation only, under a prompt that forbids it from quoting a number. That keeps the document auditable, which is the one property a positioning map has to have. The ``## Axes`` section is deliberately machine-readable: each line reads ``**Horizontal: A <-> B** -- ~NN% of the variance``, so a downstream generator can lift the pole names without a model and without this function's prose. Parameters ---------- df : pandas.DataFrame The rated table, as parsed. result : PCAResult The oriented fit. poles : list of str ``[left, right, bottom, top]``. axis_names : list of str The two ``"A <-> B"`` axis names. lang : str, optional Output language; falls back to English. title : str, optional Document title. Defaults to the reference option's name. model : str, optional Override the resolved model tag. Returns ------- str A Markdown document, always complete: when the model is unreachable the analysis section says so rather than being silently absent. """ strings = i18n(lang)["analysis_md"] evr = result.explained_variance_ratio left, right, bottom, top = poles def pct(value: float) -> str: """A variance ratio as a whole-number percentage, without the sign.""" return f"{100 * float(value):.0f}" lines = [f"# {title or result.reference}", ""] lines += [f"## {strings['axes_heading']}", ""] for label, name, share in ( (strings["horizontal"], f"{left} \u2194 {right}", evr[0]), (strings["vertical"], f"{bottom} \u2194 {top}", evr[1]), ): variance = strings["variance_axis"].format(pct=pct(share)) lines.append(f"- **{label}: {name}** \u2014 {variance}") lines += ["", strings["variance_total"].format(pct=pct(evr.sum())), ""] lines += [f"## {strings['analysis_heading']}", ""] prose = analysis_prose(df, result, poles, axis_names, lang=lang, model=model) if prose: # Surfaced rather than silently corrected: the prose is the model's # reading and editing it here would make the warning unfalsifiable. for problem in check_analysis(prose, result, lang=lang): logger.warning("written analysis: %s", problem) lines += [prose or f"*{strings['unavailable']}*", ""] filled = imputed_cells(df) if filled: lines += [f"## {strings['imputed_heading']}", "", strings["imputed_intro"], ""] lines += [f"- {option} / {criterion}" for option, criterion in filled] lines.append("") return "\n".join(lines)
[docs] def export_all( df: pd.DataFrame, result: PCAResult, roles: list[str], poles: list[str], axis_names: list[str], colors: list[str], stem: str, noun_plural: str = "Approaches", title: str | None = None, lang: str = "en", model: str | None = None, ) -> list[str]: """Write the full deliverable for one table. Returns the paths written. Three faces, not two: the figures (PNG + SVG), the written analysis (Markdown), and the YAML of coordinates + coefficients. The Markdown face was promised by this module's docstring and had stopped being written, so anything downstream looking for it found nothing and had no way to tell a missing analysis from a failed run. """ svg = to_svg( result, roles=roles, poles=poles, colors=colors, noun_plural=noun_plural, title=title, attributes=df, ) written = render_figures(svg, stem) md_path = f"{stem}.md" _atomic_write( md_path, analysis_markdown(df, result, poles, axis_names, lang=lang, title=title, model=model), ) written.append(md_path) yaml_path = f"{stem}.yaml" _atomic_write(yaml_path, results_yaml(df, result, roles, poles, axis_names, colors)) written.append(yaml_path) return written
# --------------------------------------------------------------------------- # # Convenience API: the one-liner library face # --------------------------------------------------------------------------- #
[docs] @dataclass class Positioning: """Result of `positioning()`: the map plus everything computed for it.""" df: pd.DataFrame result: PCAResult roles: list[str] poles: list[str] axis_names: list[str] colors: list[str] noun_singular: str = "Approach" noun_plural: str = "Approaches" title: str = "Approaches in the Quadrant" # fully-localized figure title lang: str = "en" # the language every naming call above was localized to @property def coords(self) -> pd.DataFrame: """Oriented (axis-1, axis-2) coordinates per option.""" return self.result.coords() @property def loadings(self) -> pd.DataFrame: """Axis loadings (criterion weights) per axis.""" return self.result.loadings() @property def axes(self) -> dict[str, str]: """The two axis names, e.g. {'x': 'Cost ↔ Innovation', 'y': ...}.""" return {"x": self.axis_names[0], "y": self.axis_names[1]} @property def role_of(self) -> dict[str, str]: """Map each option name to its role (best / worst / … / competitor).""" return dict(zip(self.result.names, self.roles, strict=False))
[docs] def to_svg(self) -> str: """The self-contained, interactive SVG document for the map.""" return to_svg( self.result, self.roles, self.poles, self.colors, noun_plural=self.noun_plural, title=self.title, attributes=self.df, # raw values, so the hover tooltip lists every column )
[docs] def to_yaml(self) -> str: """All coordinates + coefficients as YAML.""" return results_yaml( self.df, self.result, self.roles, self.poles, self.axis_names, self.colors )
[docs] def to_markdown(self) -> str: """The written analysis: the computed facts, then the model's reading.""" return analysis_markdown( self.df, self.result, self.poles, self.axis_names, lang=self.lang, title=self.title, )
[docs] def figure(self, stem: str) -> list[str]: """Render the map to `<stem>.png` and `<stem>.svg`; returns the paths.""" return render_figures(self.to_svg(), stem)
[docs] def export(self, outdir: str = ".", stem: str | None = None) -> list[str]: """Write the full deliverable into `outdir`; returns the paths.""" os.makedirs(outdir, exist_ok=True) name = stem or re.sub(r"[^A-Za-z0-9]+", "_", self.result.reference).strip("_").lower() return export_all( self.df, self.result, self.roles, self.poles, self.axis_names, self.colors, os.path.join(outdir, name), noun_plural=self.noun_plural, title=self.title, lang=self.lang, )
[docs] def positioning( data: pd.DataFrame | str, reference: int | str = 0, top: str | None = None, right: str | None = None, lower_is_better: list[str] | None = None, model: str | None = None, lang: str | None = None, ) -> Positioning: """Position options from a table in one call. `data` is a pandas DataFrame (options × numeric criteria) or a path / raw string of a CSV or Markdown table. `lower_is_better` names criteria where a lower value is better (also picked up from ``(↓)`` header markers). `top` / `right` force a named option into the top-pole / right-pole highlight (see `assign_roles`). `lang` forces the output language (one of `SUPPORTED_LANGS`); left `None` it is detected from the column names. Returns a `Positioning` with `.coords`, `.loadings`, `.axes`, `.to_svg()`, `.to_yaml()`, `.export()`. >>> pos = positioning("examples/programming_languages.csv") >>> pos.export("out") """ # The one-call pipeline, in dependency order. Everything geometric (parse, # polarity, PCA, roles) is deterministic; only the naming steps below touch the # model, so the map itself never changes run to run. df = data if isinstance(data, pd.DataFrame) else parse_table(data) df, lower = resolve_polarity(df, lower_is_better) # clean names + lower set result = analyze(df, reference=reference, lower_is_better=list(lower)) roles = assign_roles(result, top=top, right=right) # An explicit `lang` (e.g. the GUI's language toggle) wins; otherwise detect it # once from the column names. It drives every naming call (poles, noun, title) so # the whole deliverable comes out in one tongue. lang = lang if lang in SUPPORTED_LANGS else detect_language(result.features) poles = axis_poles(result, model=model, lang=lang) singular, plural = noun_forms(str(df.index.name or "Approach"), model=model, lang=lang) # Localize the whole title, not just the noun: a French table reads # "Voitures dans le quadrant", never "Voitures in the Quadrant". title = i18n(lang)["title_template"].format(plural=plural) # Bundle the geometry and the model-named parts into the façade the caller drives # (.coords / .loadings / .to_svg / .to_yaml / .export). return Positioning( df, result, roles, poles, _poles_to_names(poles), gradient_colors(result, roles), singular, plural, title, lang, )
# --------------------------------------------------------------------------- # # CLI core (the command line itself lives in standpoint/cli.py) # --------------------------------------------------------------------------- #
[docs] def run( table: str, reference: str = "0", outdir: str = "out", stem: str | None = None, top: str | None = None, right: str | None = None, lower: str = "", model: str | None = None, check: bool = False, ) -> list[str]: """Shared CLI core: build the positioning, print a summary, write the files. Called by the `standpoint` command line (`standpoint.cli`), and usable directly by any caller that wants the same printed summary. `top` / `right` force a named option into the top-pole / right-pole highlight. Returns the list of written paths. """ ref: int | str = int(reference) if reference.lstrip("-").isdigit() else reference lower_cols = [c.strip() for c in lower.split(",") if c.strip()] parsed = parse_table(table) # Say it out loud, before the map exists. A blank cell is filled with its # column's minimum (see `impute`), which is a defensible default and an # invisible one: the number reaches the YAML looking exactly like a # measurement. Worse, because each criterion is z-scored, an invented value # moves that column's mean and spread, so every option's coordinates shift, # not only the one with the gap. Whoever is reading the map on screen should # not have to open the YAML to discover that. missing = imputed_cells(parsed) if missing: shown = ", ".join(f"{option}/{criterion}" for option, criterion in missing[:5]) more = f" (+{len(missing) - 5} more)" if len(missing) > 5 else "" print( f"warning: {len(missing)} empty cell(s) filled with the column minimum: " f"{shown}{more}. Every coordinate below is affected, not just these " "options -- see meta.imputed_cells in the YAML.", file=sys.stderr, ) pos = positioning( parsed, reference=ref, top=top, right=right, lower_is_better=lower_cols, model=model, ) result, evr = pos.result, pos.result.explained_variance_ratio print(f"Parsed {pos.df.shape[0]} options x {pos.df.shape[1]} criteria") print( f"Reference '{result.reference}' rotated by {result.rotation_deg:+.1f} deg " "onto the top-right diagonal\n" ) print( f"PCA explained variance: axis-1(PC1)={evr[0]:.1%} axis-2(PC2)={evr[1]:.1%} " f"(cumulative {evr.sum():.1%})\n" ) print(f"Axis names: axis-1 = {pos.axis_names[0]!r} axis-2 = {pos.axis_names[1]!r}\n") # poles are [left, right, bottom, top]; name each highlight by its pole word. print("Highlighted options:") highlights = [ ("best", "leader (reference)"), ("worst", "weakest overall"), ("top", f"strongest toward {pos.poles[3]!r}"), ("right", f"strongest toward {pos.poles[1]!r}"), ] for role, label in highlights: who = next((n for n, r in pos.role_of.items() if r == role), "—") print(f" {label:34s}: {who}") print() print("Canonical axes in the oriented frame (loadings):") print(pos.loadings.round(3).to_string(), "\n") written = pos.export(outdir, stem=stem) print("Deliverable written:") for path in written: print(f" {path}") if check: # Computed from the coordinates, not read off a rendered PNG by a vision # model: same verdict keys, exact instead of guessed, and it needs no # model to be reachable (see `assess_layout`). verdict = assess_layout(pos) print("\nLayout self-check:") for key in ("leader_top_right", "readable", "axis_labels_visible"): print(f" {key:16s}: {verdict[key]}") print(f" notes : {verdict['notes']}") return written