from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import streamlit as st
from huggingface_hub import hf_hub_download
APP_DIR = Path(__file__).resolve().parent
ROOT = APP_DIR.parent
DATASET_DIR = ROOT / "data" / "dataset"
SOURCES_DIR = ROOT / "data" / "sources"
MODEL_METADATA_PATH = ROOT / "data" / "model_metadata" / "openrouter_models.json"
HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO", "hoololi/work-translation")
USE_HF_DATASET = os.environ.get("USE_HF_DATASET", "auto").lower()
METRICS = {
"Semantic similarity": "semantic_similarity_to_original",
"Vocabulary overlap": "word_overlap_to_original",
"Surface/edit similarity": "edit_similarity_to_original",
"Length ratio": "word_count_ratio_to_original",
}
MODEL_COLORS = {
"openai/gpt-4o-mini": "#4E79A7",
"poolside/laguna-s-2.1": "#F28E2B",
"thinkingmachines/inkling-small": "#59A14F",
"poolside/laguna-s-2.1:free": "#E15759",
}
PIVOT_COLORS = {
"English": "#8E63B6",
"Italian": "#D65F5F",
"Slovak": "#6A9F58",
"German": "#C49A2C",
"Spanish": "#3BA3A3",
}
FALLBACK_COLORS = px.colors.qualitative.Set2
METRIC_DEFINITIONS = {
"Semantic similarity": "Cosine similarity between embeddings of the original text and the translated-back text. Higher means semantically closer.",
"Vocabulary overlap": "Jaccard similarity of unique lowercased words: shared words divided by all words appearing in either text. Higher means more original vocabulary is preserved.",
"Surface/edit similarity": "Normalized word-level edit similarity. Higher means fewer insertions, deletions, substitutions, or word-order changes are needed to transform one text into the other.",
"Length ratio": "Word count of the translated-back text divided by word count of the original. 1.00 means same length; below 1.00 means shorter; above 1.00 means longer.",
}
@st.cache_data
def dataset_file(filename: str) -> Path:
"""Return a local dataset file, downloading from HF if needed/configured."""
local_path = DATASET_DIR / filename
if USE_HF_DATASET != "1" and local_path.exists():
return local_path
return Path(hf_hub_download(repo_id=HF_DATASET_REPO, repo_type="dataset", filename=filename))
def read_jsonl(path: Path) -> list[dict[str, Any]]:
if not path.exists():
return []
with path.open("r", encoding="utf-8") as f:
return [json.loads(line) for line in f if line.strip()]
def parse_source_file(path: Path) -> dict[str, Any]:
raw = path.read_text(encoding="utf-8")
lines = raw.splitlines()
meta: dict[str, str] = {}
body = raw
if lines and lines[0].strip() == "---":
end = next((i for i, line in enumerate(lines[1:], start=1) if line.strip() == "---"), None)
if end is not None:
for line in lines[1:end]:
if line.strip() and ":" in line:
key, value = line.split(":", 1)
meta[key.strip()] = value.strip().strip('"').strip("'")
body = "\n".join(lines[end + 1 :]).strip("\n")
meta["source_file"] = path.name
meta["text"] = body
return meta
@st.cache_data
def load_sources() -> pd.DataFrame:
# Prefer the dataset-level source metadata; it contains the source text and
# avoids downloading individual Markdown files from the dataset repository.
try:
sources_jsonl = dataset_file("sources.jsonl")
rows = read_jsonl(sources_jsonl)
if rows:
return pd.DataFrame(rows)
except Exception:
pass
return pd.DataFrame([parse_source_file(p) for p in sorted(SOURCES_DIR.glob("*.md"))])
@st.cache_data
def load_translations() -> pd.DataFrame:
rows = read_jsonl(dataset_file("translations.jsonl"))
if not rows:
return pd.DataFrame()
df = pd.DataFrame(rows)
for col in ["experiment_id", "timestamp", "translation_model", "provider", "pivot_language", "language_pair", "source_id", "text"]:
if col not in df.columns:
df[col] = None
df["step"] = pd.to_numeric(df["step"], errors="coerce").astype("Int64")
return df
@st.cache_data
def load_metrics() -> pd.DataFrame:
# Prefer the clean pivot-to-original table, which contains the main app metrics.
try:
path = dataset_file("pivot_to_original_metrics.csv")
except Exception:
return pd.DataFrame()
df = pd.read_csv(path)
for col in ["source_id", "pivot_language", "translation_model", "embedding_model"]:
if col not in df.columns:
df[col] = None
df = df.dropna(subset=["source_id", "pivot_language", "translation_model"])
df["step"] = pd.to_numeric(df["step"], errors="coerce").astype("Int64")
df = df[df["step"].notna() & (df["step"].astype(int) % 2 == 0)]
for col in METRICS.values():
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
keys = ["source_id", "pivot_language", "translation_model", "embedding_model", "step"]
df = df.drop_duplicates(subset=keys, keep="last")
return df
@st.cache_data
def load_model_metadata() -> dict[str, Any]:
try:
path = dataset_file("openrouter_models.json")
except Exception:
path = MODEL_METADATA_PATH
if not path.exists():
return {}
payload = json.loads(path.read_text(encoding="utf-8"))
return {m.get("id"): m for m in payload.get("models", []) if isinstance(m, dict) and m.get("id")}
def model_short(model: str) -> str:
for prefix in ["openai/", "poolside/", "thinkingmachines/", "voyageai/"]:
model = model.replace(prefix, "")
return model
def source_display_options(sources: pd.DataFrame, metric_source_ids: set[str]) -> dict[str, str]:
options: dict[str, str] = {}
for _, row in sources.iterrows():
source_id = row.get("source_id")
if source_id not in metric_source_ids:
continue
slug = row.get("source_slug") or source_id
category = row.get("category") or ""
title = row.get("title") or ""
options[f"{slug} — {title} ({category})"] = source_id
return options
def original_text_for(sources: pd.DataFrame, source_id: str) -> str:
row = sources[sources["source_id"] == source_id]
return "" if row.empty else str(row.iloc[0].get("text", ""))
def looks_like_failed_output(text: str, original_text: str) -> bool:
normalized = text.strip().casefold()
if normalized in {"fin du texte.", "fin du texte", "koniec textu.", "koniec textu"}:
return True
if original_text and len(text) < max(40, len(original_text) * 0.15):
return True
return False
def output_text(translations: pd.DataFrame, source_id: str, model: str, pivot: str, step: int) -> str:
df = translations[
(translations["source_id"] == source_id)
& (translations["translation_model"] == model)
& (translations["pivot_language"] == pivot)
& (translations["step"] == step)
]
if df.empty:
return "No text found for this source/model/pivot/step."
# If repeated runs exist with same factors, take the latest timestamp.
df = df.sort_values("timestamp", na_position="first")
return str(df.iloc[-1]["text"])
def selected_metric_row(metrics: pd.DataFrame, source_id: str, model: str, pivot: str, embedding_model: str, step: int) -> pd.Series | None:
df = metrics[
(metrics["source_id"] == source_id)
& (metrics["translation_model"] == model)
& (metrics["pivot_language"] == pivot)
& (metrics["embedding_model"] == embedding_model)
& (metrics["step"] == step)
]
if df.empty:
return None
return df.iloc[-1]
def color_map(values: list[str] | pd.Series, fixed: dict[str, str]) -> dict[str, str]:
all_values = sorted(str(x) for x in pd.Series(values).dropna().unique())
return {value: fixed.get(value, FALLBACK_COLORS[i % len(FALLBACK_COLORS)]) for i, value in enumerate(all_values)}
def model_color_map(models: list[str] | pd.Series) -> dict[str, str]:
return color_map(models, MODEL_COLORS)
def series_name(value: str, series_col: str) -> str:
return model_short(value) if series_col == "translation_model" else value
def trajectory_chart(df: pd.DataFrame, metric_labels: list[str], series_values: list[str], series_col: str, series_label: str, full_y_axis: bool):
available = [m for m in METRICS.keys() if m in metric_labels]
if not available:
return px.line(title="No metric selected")
fig = make_subplots(
rows=2,
cols=2,
subplot_titles=available + [""] * (4 - len(available)),
horizontal_spacing=0.07,
vertical_spacing=0.14,
)
fixed = MODEL_COLORS if series_col == "translation_model" else PIVOT_COLORS
colors = color_map(df[series_col], fixed)
steps = sorted(int(x) for x in df["step"].dropna().unique())
tickvals = [s for s in steps if s == 0 or s % 4 == 0 or s == max(steps)]
any_trace = False
for idx, label in enumerate(available):
row = idx // 2 + 1
col = idx % 2 + 1
metric = METRICS[label]
for value in series_values:
tmp = df[df[series_col] == value].dropna(subset=[metric]).copy()
if tmp.empty:
continue
any_trace = True
fig.add_trace(
go.Scatter(
x=tmp["step"].astype(int),
y=tmp[metric] * 100,
mode="lines+markers",
name=series_name(value, series_col),
legendgroup=value,
showlegend=idx == 0,
line=dict(width=1.8, color=colors[value]),
marker=dict(size=4, color=colors[value]),
hovertemplate=(
f"{label}
{series_label}: {series_name(value, series_col)}
"
"step: %{x}
value: %{y:.1f}%"
),
),
row=row,
col=col,
)
if not any_trace:
return px.line(title="No data for selected series")
fig.update_annotations(font_size=11, yshift=8)
fig.update_xaxes(tickmode="array", tickvals=tickvals, title_text="", showline=True, mirror=True, linewidth=1, linecolor="rgba(180,180,180,0.45)", gridcolor="rgba(180,180,180,0.18)")
fig.update_xaxes(showticklabels=False, row=1)
fig.update_xaxes(title_text="step", row=2)
if full_y_axis:
fig.update_yaxes(title_text="%", range=[0, 105], showline=True, mirror=True, linewidth=1, linecolor="rgba(180,180,180,0.45)", gridcolor="rgba(180,180,180,0.18)")
else:
fig.update_yaxes(title_text="%", showline=True, mirror=True, linewidth=1, linecolor="rgba(180,180,180,0.45)", gridcolor="rgba(180,180,180,0.18)")
fig.update_layout(height=560, margin=dict(l=8, r=8, t=55, b=8), legend=dict(orientation="h", y=1.08, font=dict(size=10), title_text=series_label), legend_itemclick=False, legend_itemdoubleclick=False, font=dict(size=11))
return fig
def selected_step_bar_chart(df: pd.DataFrame, metric_labels: list[str], series_values: list[str], series_col: str, series_label: str, step: int, full_y_axis: bool):
rows = []
for label in metric_labels:
metric = METRICS[label]
tmp = df[(df[series_col].isin(series_values)) & (df["step"] == step)].dropna(subset=[metric]).copy()
for _, r in tmp.iterrows():
value = str(r[series_col])
rows.append({
"metric": label,
"value": float(r[metric]) * 100,
series_label: series_name(value, series_col),
"raw_series": value,
})
plot_df = pd.DataFrame(rows)
if plot_df.empty:
return px.bar(title="No selected-step metric data")
fixed = MODEL_COLORS if series_col == "translation_model" else PIVOT_COLORS
cmap = {series_name(k, series_col): v for k, v in color_map(df[series_col], fixed).items()}
fig = px.bar(
plot_df,
x="metric",
y="value",
color=series_label,
barmode="group",
text_auto=".1f",
hover_data=["raw_series"],
labels={"metric": "metric", "value": f"value at step {step} (%)"},
color_discrete_map=cmap,
)
ymax = float(plot_df["value"].max()) if not plot_df.empty else 1.0
fig.update_layout(
height=340,
margin=dict(l=8, r=8, t=58, b=8),
legend=dict(orientation="h", y=1.24, x=0, font=dict(size=10), title_text=series_label, bgcolor="rgba(0,0,0,0)"),
legend_itemclick=False,
legend_itemdoubleclick=False,
font=dict(size=11),
)
if full_y_axis:
fig.update_yaxes(range=[0, 105])
else:
fig.update_yaxes(range=[0, min(130, max(5, ymax * 1.18))])
return fig
def aggregate_chart(metrics: pd.DataFrame, embedding_model: str, metric_label: str, full_y_axis: bool):
metric = METRICS[metric_label]
df = metrics[metrics["embedding_model"] == embedding_model].dropna(subset=[metric]).copy()
df["LLM"] = df["translation_model"].map(model_short)
df["value_percent"] = df[metric] * 100
fig = px.box(
df,
x="step",
y="value_percent",
color="LLM",
points="all",
hover_data=["source_id", "pivot_language", "translation_model"],
labels={"step": "step", "value_percent": f"{metric_label} (%)"},
color_discrete_map={model_short(k): v for k, v in model_color_map(metrics["translation_model"]).items()},
)
fig.update_layout(height=280, margin=dict(l=8, r=8, t=18, b=8), legend=dict(orientation="h", y=1.15, font=dict(size=10)))
if full_y_axis:
fig.update_yaxes(range=[0, 105])
return fig
def metric_cards(row: pd.Series | None) -> None:
cols = st.columns(4)
for col, (label, metric) in zip(cols, METRICS.items()):
if row is None or pd.isna(row.get(metric)):
value = "—"
else:
value = f"{float(row[metric]) * 100:.1f}%"
col.metric(label, value)
def usd_per_million_tokens(value: Any) -> str:
try:
return f"${float(value) * 1_000_000:.3f}"
except (TypeError, ValueError):
return "—"
def model_page_url(model: str) -> str:
return f"https://openrouter.ai/{model}"
def show_model_metadata_table(models: list[str], metadata: dict[str, Any]) -> None:
rows = []
for model in models:
info = metadata.get(model, {})
pricing = info.get("pricing") or {}
top_provider = info.get("top_provider") or {}
rows.append({
"Model": model_short(model),
"Context tokens": info.get("context_length", "—"),
"Max output tokens": top_provider.get("max_completion_tokens", "—"),
"Prompt USD / 1M tokens": usd_per_million_tokens(pricing.get("prompt")),
"Completion USD / 1M tokens": usd_per_million_tokens(pricing.get("completion")),
"Model page": model_page_url(model),
})
st.dataframe(
pd.DataFrame(rows),
use_container_width=True,
hide_index=True,
column_config={"Model page": st.column_config.LinkColumn("Model page")},
)
st.caption("OpenRouter prices are shown as USD per 1 million input tokens (prompt) or output tokens (completion), not per experiment or per 50-translation trajectory.")
def main() -> None:
st.set_page_config(page_title="Repeated translation explorer", layout="wide")
st.title("Repeated translation explorer")
st.markdown(
"""
What happens to a text when it is translated back and forth repeatedly by an LLM?
This experiment starts from short French texts and sends them through different pivot languages, one translation at a time. Each step is a new, stateless API call.
The resulting translation trajectories, together with several comparison metrics, form a dataset.
This app lets you explore that dataset: compare models and pivot languages, follow how a text changes over successive translations, and inspect the actual outputs behind the metrics.
Metrics are computed on returned-to-French steps only: 0, 2, 4, …
""",
unsafe_allow_html=True,
)
sources = load_sources()
translations = load_translations()
metrics = load_metrics()
model_metadata = load_model_metadata()
if translations.empty or metrics.empty:
st.error("Missing dataset export. Run `python combine_jsonl.py` and `python export_dataset.py` first.")
return
metric_source_ids = set(metrics["source_id"].dropna().unique())
source_options = source_display_options(sources, metric_source_ids)
if not source_options:
st.error("No translated sources found in metrics.")
return
with st.sidebar:
st.header("Choose an experiment")
st.markdown("**1 · Source**")
source_label = st.selectbox("Source", list(source_options.keys()), label_visibility="collapsed")
source_id = source_options[source_label]
source_metrics_all = metrics[metrics["source_id"] == source_id].copy()
st.markdown("**2 · Embedding model**")
embedding_models = sorted(source_metrics_all["embedding_model"].dropna().unique())
embedding_model = st.selectbox("Embedding model", embedding_models, label_visibility="collapsed")
source_metrics_all = source_metrics_all[source_metrics_all["embedding_model"] == embedding_model]
st.markdown("**3 · Compare**")
comparison_mode = st.radio("Comparison mode", ["LLM models", "Pivot languages"], label_visibility="collapsed")
if comparison_mode == "LLM models":
st.markdown("**4 · Pivot language**")
pivots = sorted(source_metrics_all["pivot_language"].dropna().unique())
pivot = st.selectbox("Pivot language", pivots, label_visibility="collapsed")
source_metrics = source_metrics_all[source_metrics_all["pivot_language"] == pivot].copy()
st.markdown("**5 · LLMs**")
models = sorted(source_metrics["translation_model"].dropna().unique())
selected_models = []
for model in models:
if st.checkbox(model_short(model), value=True, key=f"model_{source_id}_{pivot}_{embedding_model}_{model}"):
selected_models.append(model)
if not selected_models:
st.warning("Select at least one LLM.")
st.stop()
selected_pivots = [pivot]
chart_series = selected_models
chart_series_col = "translation_model"
chart_series_label = "LLM"
else:
st.markdown("**4 · LLM model**")
models = sorted(source_metrics_all["translation_model"].dropna().unique())
selected_model = st.selectbox("LLM model", models, format_func=model_short, label_visibility="collapsed")
source_metrics = source_metrics_all[source_metrics_all["translation_model"] == selected_model].copy()
st.markdown("**5 · Pivot languages**")
pivots = sorted(source_metrics["pivot_language"].dropna().unique())
selected_pivots = []
for pvt in pivots:
if st.checkbox(pvt, value=True, key=f"pivot_{source_id}_{selected_model}_{embedding_model}_{pvt}"):
selected_pivots.append(pvt)
if not selected_pivots:
st.warning("Select at least one pivot language.")
st.stop()
pivot = selected_pivots[0]
selected_models = [selected_model]
chart_series = selected_pivots
chart_series_col = "pivot_language"
chart_series_label = "Pivot"
selected_metric_labels = list(METRICS.keys())
st.markdown("**6 · Display**")
full_y_axis = st.checkbox("Use full 0–100% y-axis", value=False)
steps = sorted(int(x) for x in source_metrics["step"].dropna().unique())
source_row = sources[sources["source_id"] == source_id]
source_meta = source_row.iloc[0] if not source_row.empty else pd.Series(dtype=object)
with st.container(border=True):
st.markdown(f"#### Selected source: {source_label}")
meta_cols = st.columns(4)
meta_cols[0].write(f"**Source id:** {source_id}")
meta_cols[1].write(f"**Category:** {source_meta.get('category', '')}")
meta_cols[2].write(f"**Mode:** {comparison_mode}")
meta_cols[3].write(f"**Embedding:** {embedding_model}")
with st.expander("Source/provenance details", expanded=False):
st.write(f"**Title:** {source_meta.get('title', '')}")
st.write(f"**Source:** {source_meta.get('source', '')}")
st.write(f"**License:** {source_meta.get('license', '')}")
with st.expander("What do these metrics mean?", expanded=False):
st.markdown("""
""", unsafe_allow_html=True)
for label, definition in METRIC_DEFINITIONS.items():
st.markdown(
f'',
unsafe_allow_html=True,
)
st.markdown("#### Metric trajectories")
st.plotly_chart(trajectory_chart(source_metrics, selected_metric_labels, chart_series, chart_series_col, chart_series_label, full_y_axis), use_container_width=True)
st.markdown("#### Texts")
original_text = original_text_for(sources, source_id)
with st.expander("Original text", expanded=True):
st.text_area("Original", original_text, height=220, label_visibility="collapsed")
step = st.select_slider(
"Returned-to-original-language step",
options=steps,
value=max(steps),
key=f"step_{source_id}_{comparison_mode}_{embedding_model}",
)
if comparison_mode == "LLM models":
cols = st.columns(len(selected_models))
for col, model in zip(cols, selected_models):
with col:
st.markdown(f"**{model_short(model)} · {pivot} · step {step}**")
out = output_text(translations, source_id, model, pivot, int(step))
if looks_like_failed_output(out, original_text):
st.warning("Possible failed translation / end marker.")
st.text_area(model, out, height=360, label_visibility="collapsed")
else:
model = selected_models[0]
cols = st.columns(min(len(selected_pivots), 3))
for col, pvt in zip(cols, selected_pivots[:3]):
with col:
st.markdown(f"**{pvt} pivot · {model_short(model)} · step {step}**")
out = output_text(translations, source_id, model, pvt, int(step))
if looks_like_failed_output(out, original_text):
st.warning("Possible failed translation / end marker.")
st.text_area(f"{pvt} pivot", out, height=360, label_visibility="collapsed")
st.markdown(f"#### Selected-step metrics · step {step}")
if comparison_mode == "LLM models":
tabs = st.tabs([model_short(m) for m in selected_models])
for tab, model in zip(tabs, selected_models):
with tab:
metric_cards(selected_metric_row(metrics, source_id, model, pivot, embedding_model, int(step)))
else:
tabs = st.tabs(selected_pivots)
model = selected_models[0]
for tab, pvt in zip(tabs, selected_pivots):
with tab:
metric_cards(selected_metric_row(metrics, source_id, model, pvt, embedding_model, int(step)))
st.markdown(f"#### Selected-step comparison chart · step {step}")
st.plotly_chart(selected_step_bar_chart(source_metrics, selected_metric_labels, chart_series, chart_series_col, chart_series_label, int(step), full_y_axis), use_container_width=True)
with st.expander("Table: all pivots/models for this source and step", expanded=False):
table = metrics[(metrics["source_id"] == source_id) & (metrics["step"] == int(step)) & (metrics["embedding_model"] == embedding_model)].copy()
cols = ["translation_model", "pivot_language", "step"] + list(METRICS.values())
st.dataframe(table[[c for c in cols if c in table.columns]].sort_values(["pivot_language", "translation_model"]), use_container_width=True, hide_index=True)
with st.expander("Aggregate distribution across experiments", expanded=False):
agg_metric = st.selectbox("Aggregate metric", list(METRICS.keys()))
st.plotly_chart(aggregate_chart(metrics, embedding_model, agg_metric, full_y_axis), use_container_width=True)
with st.expander("Model metadata", expanded=True):
all_models_for_source = sorted(source_metrics_all["translation_model"].dropna().unique())
show_model_metadata_table(all_models_for_source, model_metadata)
if __name__ == "__main__":
main()