standpoint package
Submodules
Module contents
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
- parseCSV or Markdown table -> numeric DataFrame (blanks -> minimum value
of the non-blank, non-NaN values in that column).
- preparenormalization (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.
- pca_2dPCA onto 2 components, keeping the canonical axes (loadings) so
every axis stays a readable linear combination of the criteria.
- orientrigidly 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.
- class standpoint.PCAResult(names, features, scores, components, explained_variance_ratio, rotation_deg, reference, x_std, lower=frozenset({}))[source]
Bases:
objectThe oriented 2D PCA of one comparison table: the map’s raw geometry.
Produced by
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, soscoresare ready to plot.- Parameters:
- scores
(n, 2)oriented coordinates, one row per option (axis-1, axis-2).- Type:
np.ndarray
- components
(2, p)oriented canonical axes, i.e. the per-criterion loadings.- Type:
np.ndarray
- explained_variance_ratio
Fraction of variance each of the two axes carries, from the PCA fit.
- Type:
np.ndarray
- rotation_deg
The rotation (degrees) applied to bring the reference onto the +45° diagonal.
- Type:
- x_std
(n, p)normalized feature matrix that was fed to the PCA.- Type:
np.ndarray
- components: ndarray
- explained_variance_ratio: ndarray
- loadings()[source]
Criterion weights per oriented axis, as a features x (axis-1, axis-2) frame.
- Return type:
DataFrame
- scores: ndarray
- x_std: ndarray
- class standpoint.Positioning(df, result, roles, poles, axis_names, colors, noun_singular='Approach', noun_plural='Approaches', title='Approaches in the Quadrant', lang='en')[source]
Bases:
objectResult of positioning(): the map plus everything computed for it.
- Parameters:
- property coords: DataFrame
Oriented (axis-1, axis-2) coordinates per option.
- df: DataFrame
- property loadings: DataFrame
Axis loadings (criterion weights) per axis.
- standpoint.analysis_markdown(df, result, poles, axis_names, *, lang='en', title=None, model=None)[source]
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
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
## Axessection 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:
- Returns:
A Markdown document, always complete: when the model is unreachable the analysis section says so rather than being silently absent.
- Return type:
- standpoint.analysis_prose(df, result, poles, axis_names, *, lang='en', model=None)[source]
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
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:
- Returns:
Two or three paragraphs, or
""when no model answered.- Return type:
- standpoint.analyze(df, reference=0, soften_reference=1.0, lower_is_better=None)[source]
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.
- standpoint.assess_layout(pos, *, crowding=0.045)[source]
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
--checkruns.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:
{"leader_top_right": bool, "readable": bool, "axis_labels_visible": bool, "notes": str}, the notes localized topos.lang.- Return type:
- standpoint.assign_roles(result, top=None, right=None)[source]
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 (str, optional) – Force a specific option into the top-pole / right-pole highlight by exact name, bypassing the geometric pick.
right (str, optional) – Force a specific option into the top-pole / right-pole highlight by exact name, bypassing the geometric pick.
- Returns:
One role per option, aligned with
result.names; collisions resolve byROLE_ORDER(best beats worst beats the two champions).- Return type:
- standpoint.axis_poles(result, model=None, lang=None)[source]
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).
- standpoint.check_analysis(prose, result, *, lang='en')[source]
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
analysis_prose().result (PCAResult) – The oriented fit the prose is describing.
lang (str, optional) – Which language’s direction vocabulary to read.
- Returns:
One message per contradicted claim, de-duplicated and sorted. Empty means every spatial statement in the prose agrees with the map.
- Return type:
- standpoint.detect_language(texts)[source]
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.
- standpoint.engine()[source]
Return the resolved LLM/VLM engine descriptor for Standpoint.
Thin cache over
best_engine_ai_helper.ensure(): loadsstandpoint/llm.engine.yaml(backend + per-kind model for this machine), or resolves it from the committedstandpoint/llm.brief.yamlon first use and writes it. Raises loudly if the brief is missing. Nothing is computed at import time; the first LLM call triggers resolution.- Return type:
- standpoint.export_all(df, result, roles, poles, axis_names, colors, stem, noun_plural='Approaches', title=None, lang='en', model=None)[source]
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.
- standpoint.gradient_colors(result, roles)[source]
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.
- standpoint.i18n(lang='en')[source]
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.
- standpoint.parse_table(source)[source]
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.
- Parameters:
source (str)
- Return type:
DataFrame
- standpoint.png_on_white(svg)[source]
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.
- standpoint.positioning(data, reference=0, top=None, right=None, lower_is_better=None, model=None, lang=None)[source]
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")
- standpoint.render_figures(svg, stem)[source]
Write an SVG figure (and its raster companion) as transparent and white pairs.
svg is the transparent figure from
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.
- standpoint.resolve_polarity(df, lower_is_better=None)[source]
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.
- standpoint.results_yaml(df, result, roles, poles, axis_names, colors)[source]
Everything about the fit as YAML: metadata, axis loadings, and per-approach coordinates, roles, colours, and original attribute values.
- standpoint.run(table, reference='0', outdir='out', stem=None, top=None, right=None, lower='', model=None, check=False)[source]
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.
- standpoint.suggest_ratings(noun, options, criteria, model=None, lang=None)[source]
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.
criteria (list[str]) – The criterion (column) names to score each option on.
model (str | None) – Force a specific model tag;
Noneuses the one resolved in the engine.lang (str | None) – Output language for the prompt; detected from the names when None.
- Returns:
{option: {criterion: rating}}with every rating clamped to 1..5. Missing pairs default to 3 (neutral) so the caller always gets a complete matrix.- Return type:
- Raises:
RuntimeError – The local model backend is unreachable, or the model errors.
- standpoint.to_svg(result, roles=None, poles=None, colors=None, noun_plural='Approaches', title=None, attributes=None)[source]
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/:focuslift — no JavaScript, no external renderer. The background is transparent;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.
- standpoint.validate_table(df)[source]
Raise a clear
ValueErrorif 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.
- Parameters:
df (DataFrame)
- Return type:
None
- standpoint.vlm_assess(image, model=None, lang=None)[source]
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.