---
title: "Cross-language handoff: R to JSON to Python to scikit-learn"
author: "Selçuk Korkmaz"
date: "`r Sys.Date()`"
output:
  rmarkdown::html_vignette:
    toc: true
vignette: >
  %\VignetteIndexEntry{Cross-language handoff: R to JSON to Python to scikit-learn}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include=FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>",
  message = FALSE,
  warning = FALSE
)
```

`split_spec` is designed as an *interchange format*, not as internal plumbing
for any one downstream package. This vignette shows the full path: derive a
constraint in R, serialize it to JSON, read it in Python with the shipped
`splitspec` reference consumer, and hand the recovered grouping straight to a
scikit-learn resampler.

The Python chunks below are shown but not executed, so building the vignette
needs no Python. To keep the central claim honest rather than asserted, the
vignette *does* run the shipped Python reader through R whenever a working
Python 3 is on the `PATH` (see "Verify the round-trip"), and shows that the
grouping, ordering and stratum it recovers match R's exactly. Where no
interpreter is found it says so rather than quietly printing nothing.

# Derive and serialize in R

```{r derive}
library(splitGraph)

meta <- data.frame(
  sample_id    = c("S1", "S2", "S3", "S4", "S5"),
  subject_id   = c("P1", "P1", "P2", "P3", "P3"),
  timepoint_id = c("T0", "T1", "T0", "T2", "T0"),
  time_index   = c(0, 1, 0, 2, 0),
  outcome_id   = c("case", "case", "ctrl", "ctrl", "ctrl"),
  stringsAsFactors = FALSE
)

g <- graph_from_metadata(meta, graph_name = "handoff-demo")

# Group so that repeated measures of the same subject never straddle a split.
constraint <- derive_split_constraints(g, mode = "subject")
spec <- as_split_spec(constraint, graph = g)

path <- tempfile(fileext = ".json")
write_split_spec(spec, path)
```

The written file carries a `$schema` reference and a `schema_version`, and can
be validated against the shipped JSON Schema before it ever leaves R:

```{r validate}
report <- validate_split_spec_json(path)
report$valid

# The R-side grouping we expect Python to reproduce:
grouping_vector(constraint)

# The outcome travels with the spec as a stratum annotation, so a consumer can
# stratify without touching the graph. splitGraph never balances folds itself.
spec$stratum_var
spec$sample_data$stratum
```

# What is actually on disk

Before leaving R it is worth looking at the artifact itself, because that file
— not any R or Python object — is the contract. It is a single JSON object with
a small, flat shape: scalar declarations at the top, then one row per sample.

```{r json-shape, eval = requireNamespace("jsonlite", quietly = TRUE)}
on_disk <- jsonlite::fromJSON(path, simplifyVector = FALSE)
names(on_disk)

# One sample row. Every declared role above names a column in here.
str(on_disk$sample_data[[1]])
```

The top-level fields say how to read the rows: `group_var` names the grouping
column, `stratum_var` the stratum, `time_var` the ordering, `block_vars` the
blocking columns. A consumer keys on those names rather than hard-coding
`group_id`, which is what lets the same file drive tools that have never heard
of each other. Columns that do not apply to this cohort are `null`, not absent,
so the row shape is the same for every sample.

# Read in Python

The reference consumer lives in the installed package under `inst/python`. On
the R side its location is:

```{r pypath}
system.file("python", package = "splitGraph")
```

That directory is a complete, installable package — `pip install` it, or just
put it on `sys.path`:

```bash
pip install "<the inst/python path printed above>"            # reader only
pip install "<the inst/python path printed above>[sklearn]"   # + sklearn helpers
```

The reader itself imports nothing outside the standard library; `to_frame()`
pulls in pandas and the resampler helpers import scikit-learn, both lazily, so a
consumer that only needs the grouping pays for neither. Then:

```python
import sys
# sys.path.append(<the inst/python path printed above>)
from splitspec import load_split_spec

spec = load_split_spec("split_spec.json")

spec.schema_version        # "0.3.0"
spec.constraint_mode       # "subject"
spec.recommended_resampling  # "grouped_cv"
spec.stratum_var           # "stratum"
spec.strata()              # ['case', 'case', 'ctrl', 'ctrl', 'ctrl']

# Grouping keyed by sample_id — identical to R's grouping_vector():
spec.grouping()
# {'S1': 'subject:P1', 'S2': 'subject:P1', 'S3': 'subject:P2',
#  'S4': 'subject:P3', 'S5': 'subject:P3'}

df = spec.to_frame()       # pandas DataFrame of sample_data
```

That is not the whole surface. The reader mirrors every top-level field of the
file and adds the accessors a resampler needs:

| Attribute | From the file |
|---|---|
| `schema_version`, `constraint_mode`, `constraint_strategy` | provenance of the partition |
| `group_var`, `stratum_var`, `time_var`, `block_vars` | which column plays which role |
| `ordering_required` | whether the consumer *must* respect the ordering |
| `recommended_resampling` | the routine R suggests, as a plain string |
| `metadata` | the free-form block, including any `warnings` R recorded |
| `sample_data`, `sample_ids` | the rows, in file order |

| Method | Returns |
|---|---|
| `groups()` | `group_var` per sample, in file order |
| `strata(column=None)` | stratum per sample; any other column on request |
| `order_ranks()` | `order_rank` per sample, `None` where undetermined |
| `ordered_index()` | row indices sorted by `order_rank`, missing ranks last |
| `grouping()` | `{sample_id: group_id}` — the analogue of `grouping_vector()` |
| `to_frame()` | `sample_data` as a pandas DataFrame |
| `group_kfold()`, `stratified_group_kfold()` | scikit-learn splitters, wired up |

Everything above `to_frame()` is standard library only.

## Versions are part of the contract

The reader checks `schema_version` and accepts any file whose **major** version
it understands — currently major `0`, matching the R side's policy. A minor bump
only ever adds fields, so an older file loads silently with the new fields
absent: a spec written before schema 0.3.0 has no stratum, and there
`spec.stratum_var` is `None` rather than an error. A future major would be
refused outright with a `ValueError` naming the version, instead of being
misread.

Going the other way, `migrate_split_spec_json()` on the R side rewrites an old
file at the current version, filling anything added since with `null`. So a
consumer has two options for an aged archive and neither of them is guessing.

# Verify the round-trip

Rather than take the comment above on faith, we can run the shipped Python
reader on the exact file we just wrote and compare what it recovers to R's
`grouping_vector()`. This is what `inst/python/conformance.py` does; the chunk
below invokes it through R, so the vignette still builds without Python but
says which of the two happened rather than falling silent.

Finding the interpreter takes a little care. A name on the `PATH` is not proof
of a Python: Windows ships `python3.exe` and `python.exe` launcher stubs that
print "Python not found" and exit non-zero, so `Sys.which("python3")` can
succeed where actually running it does not. The helper below probes each
candidate by executing it and keeps the first that reports a Python 3 — the
same rule the package's own conformance test uses.

```{r find-python}
find_python <- function() {
  for (name in c("python3", "python")) {
    candidate <- Sys.which(name)
    if (!nzchar(candidate)) next
    probe <- tryCatch(
      suppressWarnings(system2(
        candidate, c("-c", shQuote("import sys; print(sys.version_info[0])")),
        stdout = TRUE, stderr = TRUE
      )),
      error = function(e) character()
    )
    if (is.null(attr(probe, "status")) && any(trimws(probe) == "3")) return(candidate)
  }
  ""
}

python <- find_python()
nzchar(python)
```

```{r conformance, eval = requireNamespace("jsonlite", quietly = TRUE)}
if (!nzchar(python)) {
  cat("No usable Python 3 found; skipping the round-trip check.\n")
} else {
  script   <- system.file("python", "conformance.py", package = "splitGraph")
  out_path <- tempfile(fileext = ".json")

  # Run the Python reader on our JSON file; it writes back what it recovered.
  status <- suppressWarnings(system2(
    python, c("-B", shQuote(script), shQuote(path), shQuote(out_path)),
    stdout = FALSE, stderr = FALSE
  ))

  if (!identical(status, 0L) || !file.exists(out_path)) {
    cat("The Python reader could not be run (exit status ", status, ").\n", sep = "")
  } else {
    recovered  <- jsonlite::fromJSON(out_path)
    r_grouping <- grouping_vector(constraint)
    ids        <- spec$sample_data$sample_id

    # Grouping recovered by Python:
    print(unlist(recovered$grouping))

    # Identical to the grouping R produced?
    cat("grouping matches:",
        identical(unlist(recovered$grouping)[names(r_grouping)],
                  r_grouping[names(r_grouping)]), "\n")

    # The script returns the ordering and the stratum annotation too.
    cat("order_rank matches:",
        identical(as.integer(unlist(recovered$order_ranks)[ids]),
                  as.integer(spec$sample_data$order_rank)), "\n")
    cat("stratum matches:",
        identical(unname(unlist(recovered$strata)[ids]),
                  spec$sample_data$stratum), "\n")
    unlink(out_path)
  }
}
```

The package's test suite runs exactly this comparison as an automated
conformance test (`test-python-conformance.R`, skipped when Python is absent and
never on CRAN), so the two implementations cannot drift apart unnoticed between
releases. The point is that the partition is *decided once* in R and only
*reproduced* elsewhere — the two languages cannot disagree.

# Drive scikit-learn

The grouping vector plugs directly into `GroupKFold` (or
`StratifiedGroupKFold`), guaranteeing that all samples from a subject land in
the same fold:

```python
import numpy as np
from sklearn.model_selection import GroupKFold

groups = spec.groups()          # group_id per sample, in file order
X = np.zeros((len(groups), 1))  # placeholder design matrix

for train_idx, test_idx in GroupKFold(n_splits=3).split(X, groups=groups):
    train_groups = {groups[i] for i in train_idx}
    test_groups  = {groups[i] for i in test_idx}
    assert train_groups.isdisjoint(test_groups)  # no subject leaks across

# Same thing, with the reader building the placeholder X for you:
for train_idx, test_idx in spec.group_kfold(n_splits=3):
    ...
```

To keep the outcome balanced across folds *as well as* keeping subjects
together, use the stratum annotation. The reader has a helper that wires both
in, so the caller never has to line up the two vectors by hand:

```python
from sklearn.model_selection import StratifiedGroupKFold

# Explicit form: grouping and stratum come from the same spec, in file order.
for train_idx, test_idx in StratifiedGroupKFold(n_splits=2).split(
        X, y=spec.strata(), groups=spec.groups()):
    ...

# Equivalent one-liner:
for train_idx, test_idx in spec.stratified_group_kfold(n_splits=2):
    ...
```

`strata()` returns the `stratum` column by default; pass a column name to
stratify on something else, such as a blocking variable. It never raises — when
the spec carries no stratum it returns `None` for every sample, which is what a
file written before schema 0.3.0 looks like (`spec.stratum_var` is `None`
there). The check happens one level up: `stratified_group_kfold()` raises
`ValueError` rather than handing scikit-learn a `y` full of `None`.

For an ordered evaluation (a `mode = "time"` spec), sort by `order_rank` first
and use `TimeSeriesSplit`:

```python
from sklearn.model_selection import TimeSeriesSplit

order = spec.ordered_index()    # row indices sorted by order_rank
df_ordered = spec.to_frame().iloc[order].reset_index(drop=True)

for train_idx, test_idx in TimeSeriesSplit(n_splits=3).split(df_ordered):
    ...
```

# Why this matters

The leakage-aware partition is decided *once*, in R, from explicit and validated
dependency structure — and every other language merely *reproduces* it from the
`split_spec`. Nothing about the split logic is re-implemented in Python, so the
two sides cannot drift. `split_spec` is the contract; scikit-learn (here) and
`rsample` (on the R side) are just interchangeable consumers of it. That is what
makes it an interchange format rather than internal plumbing for any one tool.

```{r cleanup, include = FALSE}
unlink(path)
```
