Source code for standpoint

"""Standpoint: know where each option actually stands.

Explainable 2D PCA positioning map from any comparison table.

Turn a table of *approaches x criteria* (CSV or Markdown, numeric ratings on any
scale) into a competitive positioning map, plus a written interpretation and 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.8.3"

import argparse
import logging
import math
import os
import re
import sys
from dataclasses import dataclass

import best_engine_ai_helper as beh
import numpy as np
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://harchaoui.org/warith/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, the
# written analysis, and the visual self-check of the rendered figure (`vlm_assess`).
# 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 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", "export_all", "analysis_markdown", "suggest_ratings", "results_yaml", "validate_table", "resolve_polarity", "detect_language", "i18n", "vlm_assess", "engine", "run", "main", ] # --------------------------------------------------------------------------- # # 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") _I18N_CACHE: dict | None = None
[docs] def i18n(lang: str = "en") -> dict: """Prompt templates for `lang` (falls back to English), loaded from i18n.yaml.""" global _I18N_CACHE if _I18N_CACHE is None: with open(_I18N_PATH, encoding="utf-8") as fh: _I18N_CACHE = yaml.safe_load(fh) return _I18N_CACHE.get(lang, _I18N_CACHE["en"])
[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 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. """ 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]] = {} for i in corners + others: 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) found = None # (box, (lx, ly, anchor)): the first direction, first ring, that fits 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 keepin_x is not None and (box[0] < -keepin_x or box[2] > keepin_x): continue if keepin_y is not None and (box[1] < -keepin_y or box[3] > keepin_y): 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 # 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. found = (box, (float(lx_px * sx), float(ly_px * sy), anchor)) break if found is not None: break 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)
# --------------------------------------------------------------------------- # # Three-fold export: figures (PNG + SVG), 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)
[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" with open(svg_path, "w", encoding="utf-8") as fh: fh.write(variant) with open(png_path, "wb") as fh: fh.write(_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) -> 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). 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. 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 = ( "This image is a 2D competitor positioning map. The single RED dot is the " "leader and should sit in the TOP-RIGHT area. The four axis poles are named " "in italic text at the top, bottom, left, and right edges. Assess three " "things: (1) is the red leader dot in the top-right? (2) are the point " "labels readable and not badly overlapping? (3) are the four italic axis " "pole labels at the edges present and legible? Reply as JSON." ) 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 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 def _llm_text(prompt: str, model: str | None, fallback: str) -> str: """Free-text completion from the local model; `fallback` if unreachable.""" try: text = llm.chat(prompt, engine=engine(), kind="vlm", temperature=0.3, model=model) return (text.strip() if isinstance(text, str) else "") or fallback except Exception: return fallback def _approx_pct(fraction: float) -> str: """Format a fraction as an approximate percentage: nearest 5, with a '~' prefix. An exact figure like '89%' reads as false precision in a written takeaway; '~90%' conveys the same magnitude at an honest resolution. """ return f"~{round(fraction * 20) * 5}%"
[docs] def analysis_markdown( result: PCAResult, roles: list[str], poles: list[str], model: str | None = None, lang: str | None = None, ) -> str: """A thoughtful, precise interpretation of the map as Markdown. Combines data-derived facts (axis loadings, variance, roles, coordinates) with an LLM-written narrative in the table's own language (auto-detected). Falls back to a templated narrative when the model is unavailable. """ left, right, bottom, top = poles evr = result.explained_variance_ratio names = result.names role_of = dict(zip(names, roles, strict=False)) coords = result.coords() if lang is None: lang = detect_language(result.features) def order_line(k: int) -> str: """Axis `k`'s criteria in order, from the ones pulling toward its positive (right/top) pole to those pulling toward the negative one. Names only: the ordering is what a reader can use; the raw weights are noise here. """ pairs = sorted( zip(result.features, result.components[k], strict=False), key=lambda t: -t[1] ) return " · ".join(f for f, _ in pairs) ranked = sorted(names, key=lambda n: -(coords.loc[n].sum())) role_rows = { r: next((n for n, rr in role_of.items() if rr == r), "—") for r in ("best", "worst", "top", "right") } narrative = _llm_text( i18n(lang)["narrative_prompt"].format( left=left, right=right, bottom=bottom, top=top, reference=result.reference, best=role_rows["best"], worst=role_rows["worst"], champ_top=role_rows["top"], champ_right=role_rows["right"], leaderboard=", ".join(ranked[:8]), ), model, fallback=( f"The map's horizontal axis contrasts **{left}** (left) with **{right}** " f"(right); the vertical contrasts **{bottom}** (bottom) with **{top}** " f"(top), together capturing {_approx_pct(evr.sum())} of the information that tells " f"these approaches apart. **{result.reference}** anchors the top-right as the " f"reference leader, strongest on the {right.lower()} and {top.lower()} " f"directions. **{role_rows['worst']}** sits opposite as the weakest on " f"these dimensions, while among the challengers **{role_rows['top']}** " f"reaches furthest toward {top.lower()} and **{role_rows['right']}** " f"furthest toward {right.lower()}." ), ) # The structural labels (headings and fixed lines) are localized so the whole # report follows `lang`, matching the localized narrative and pole names above. a = i18n(lang).get("analysis", i18n("en")["analysis"]) horiz = a["axis_horizontal"].format(left=left, right=right) vert = a["axis_vertical"].format(bottom=bottom, top=top) lines = [ f"# {result.reference}", "", f"## {a['interpretation']}", "", narrative, "", f"## {a['axes']}", "", f"**{horiz}** {a['info_share'].format(pct=_approx_pct(evr[0]))}", "", a["relevant_columns"].format(cols=order_line(0)), "", f"**{vert}** {a['info_share'].format(pct=_approx_pct(evr[1]))}", "", a["relevant_columns"].format(cols=order_line(1)), "", a["preserved"].format(pct=_approx_pct(evr.sum())), "", f"## {a['highlighted']}", "", f"- **{a['leader']}** {role_rows['best']}", f"- **{a['opposite']}** {role_rows['worst']} {a['opposite_note']}", f"- **{a['strongest_top'].format(top=top)}** {role_rows['top']} {a['top_note']}", f"- **{a['strongest_right'].format(right=right)}** {role_rows['right']} {a['right_note']}", "", ] return "\n".join(lines)
[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 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), }, "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}, } 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 export_all( df: pd.DataFrame, result: PCAResult, roles: list[str], poles: list[str], axis_names: list[str], colors: list[str], stem: str, model: str | None = None, noun_plural: str = "Approaches", title: str | None = None, ) -> list[str]: """Write the full three-fold deliverable for one table: figures (PNG + SVG), a Markdown interpretation, and a YAML of coordinates + coefficients. Returns the list of paths written. """ svg = to_svg( result, roles=roles, poles=poles, colors=colors, noun_plural=noun_plural, title=title ) written = render_figures(svg, stem) for path, text in [ (f"{stem}.md", analysis_markdown(result, roles, poles, model)), (f"{stem}.yaml", results_yaml(df, result, roles, poles, axis_names, colors)), ]: with open(path, "w", encoding="utf-8") as fh: fh.write(text) written.append(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 @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_markdown(self, model: str | None = None) -> str: """The written interpretation as Markdown.""" return analysis_markdown(self.result, self.roles, self.poles, model)
[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 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, model: str | None = None, ) -> list[str]: """Write the full three-fold 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), model=model, noun_plural=self.noun_plural, title=self.title, )
[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_markdown()`, `.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_markdown / .to_yaml / .export). return Positioning( df, result, roles, poles, _poles_to_names(poles), gradient_colors(result, roles), singular, plural, title, )
# --------------------------------------------------------------------------- # # CLI # --------------------------------------------------------------------------- #
[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. Used by both the argparse (`main`) and click (`main_click`) entry points. `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()] pos = positioning( parse_table(table), 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, model=model) print("Three-fold deliverable written:") for path in written: print(f" {path}") if check: # Assess a white-composited render, not the transparent PNG on disk: the # vision model's backend would otherwise flatten transparency onto black and # wrongly report the dark legend as cut off (see `png_on_white`). verdict = vlm_assess(png_on_white(pos.to_svg()), model=model) if verdict: print("\nVision self-check:") for key in ("leader_top_right", "readable", "legend_visible"): print(f" {key:16s}: {verdict.get(key)}") if verdict.get("notes"): print(f" notes : {verdict['notes']}") else: print("\nVision self-check unavailable (model not reachable).") return written
[docs] def main(argv: list[str] | None = None) -> None: """argparse entry point (console command ``standpoint``).""" ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("table", help="path to a markdown or CSV table") ap.add_argument( "-r", "--reference", default="0", help="row placed top-right: index (default 0) or exact name", ) ap.add_argument( "-o", "--outdir", default="out", help="output directory for the three-fold deliverable (default out/)", ) ap.add_argument("--stem", help="basename for outputs (default: derived from reference)") ap.add_argument( "--top", help="exact name of the option to highlight as strongest " "toward the top pole (default: picked from the map)", ) ap.add_argument( "--right", help="exact name of the option to highlight as strongest " "toward the right pole (default: picked from the map)", ) ap.add_argument( "--lower", default="", help="comma-separated criteria where lower is better (e.g. Price,Latency)", ) ap.add_argument( "--model", default=None, help="override the local model tag (default: the one resolved in llm.engine.yaml)", ) ap.add_argument( "--check", action="store_true", help="ask the vision model to sanity-check the rendered figure", ) a = ap.parse_args(argv) try: run(a.table, a.reference, a.outdir, a.stem, a.top, a.right, a.lower, a.model, a.check) except Exception as err: # noqa: BLE001 — last resort: a clean CLI error, not a traceback print(f"Error: {err}", file=sys.stderr) sys.exit(1)
if __name__ == "__main__": main()