---
title: "Build, preserve and evaluate a contrast direction"
format: html
engine: knitr
vignette: >
  %\VignetteIndexEntry{Build, preserve and evaluate a contrast direction}
  %\VignetteEngine{quarto::html}
  %\VignetteEncoding{UTF-8}
---

A contrast direction summarizes how paired activations at a declared site differ. Its
positive sign is **target minus control**. Adding it during inference is an
experiment, not a guarantee that the desired behavior will improve. Keep
construction, coefficient selection and final evaluation separate.

## A complete, model-free artifact

These four-coordinate matrices and provenance records are **synthetic**. The
checksums below are labels for the example, not measurements or identities of
loaded model weights. The numerical answer is independently specified as
`c(2, 1, 0, 0) / sqrt(5)`.

```{r}
#| label: setup
#| include: false
# R CMD build records its installation here. Quarto on Windows can lose the
# temporary library when starting its R subprocess (quarto-r issue #217).
build_library <- Sys.getenv("R_BUILD_TEMPLIB")
if (nzchar(build_library)) .libPaths(c(build_library, .libPaths()))
```

```{r}
library(relm)
control <- rbind(c(2, 0, -1, 0.5), c(0, 3, 1, 2))
target <- control + rbind(c(1, 2, 0, 0), c(3, 0, 0, 0))
dimnames(target) <- dimnames(control) <- list(c("pair-a", "pair-b"), as.character(1:4))
hashes <- sprintf("%064x", 1:6)
context <- list(
  model = list(sha256 = strrep("a", 64), architecture = "synthetic",
    quantization = "synthetic", hidden_size = 4L, layers = 3L,
    engine_revision = "synthetic-example-v1"),
  capture = list(component = "residual", positions = "last",
    input_format = "raw_text", tokenizer = "gguf_embedded",
    add_special = TRUE, parse_special = FALSE, template_sha256 = NULL,
    context_length = 128L, backend = "cpu", relm_version = "0.3.0"),
  pairs = data.frame(pair_id = rownames(target),
    target_sha256 = hashes[1:2], control_sha256 = hashes[3:4],
    target_pos = c(3L, 5L), control_pos = c(4L, 6L)),
  splits = data.frame(prompt_sha256 = hashes,
    split = c(rep("construction", 4), "selection", "evaluation")),
  seed = NULL
)
direction <- llm_direction(target, control, context, layer = 2)
stopifnot(max(abs(direction$value - c(2, 1, 0, 0) / sqrt(5))) < 1e-12)
direction
```

The ordinary data-frame columns are `neuron` and `value`. The single `direction`
attribute retains the model/capture/pair/split records, construction method,
per-pair and aggregate norms, producer versions and canonical checksums.
Neither the matrices nor a model pointer is retained.

```{r}
#| fig-width: 10
#| fig-height: 7
barplot(direction$value, names.arg = direction$neuron,
  xlab = "Residual coordinate", ylab = "Unit-direction value",
  main = "Synthetic paired contrast: target minus control", col = "steelblue")
abline(h = 0)
attr(direction, "direction")$diagnostics$pairs
```

By default, larger pair differences contribute more. `normalize_pairs = TRUE`
gives each pair unit weight before averaging. `orthogonalize = TRUE` then
removes the component parallel to the mean of the **original** controls. Both
are explicit choices, not automatic improvements. Near-zero pairs, cancelled
means, nonfinite values and degenerate projections are errors; no pair is
silently discarded. The result always has unit Euclidean length.

## Trusted persistence and integrity

```{r}
path <- tempfile(fileext = ".rds")
saveRDS(direction, path, version = 3)
restored <- readRDS(path)
stopifnot(identical(restored, direction))
print(restored) # validates the artifact before printing
unlink(path)
```

Use RDS only for trusted data. Reading an arbitrary RDS file is not made safe by
these functions. Printing and application check the complete schema, full neuron
order, unit norm, diagnostic invariants and checksums. Modification of a value,
layer or recorded model is rejected before native derivation. Checksums detect
modification; someone who changes both an artifact and its checksums can create
another consistent artifact. Recorded provenance is not authentication.

## Collect real inputs deliberately

For real work, verify the pinned GGUF file **before loading**, keep it unchanged
throughout the experiment and retain its checksum. Record the actual patched
engine build, model metadata and capture settings. Raw prompt tracing uses
`add_special=TRUE` and `parse_special=FALSE`; do not reconstruct its source
positions using `llm_tokens()` or assume that generation uses identical special-
token parsing flags. Read positions from the actual trace.

The collection loop should capture one prompt at a time on the unintervened
handle, select one complete residual vector and release the trace. Preflight
the final pair of matrices before collection. Do not concatenate all layers or
silently fill missing neurons.

```{r}
#| eval: false
# m is the original handle created from the independently verified cached file.
tr <- llm_trace(m, construction_prompt, layers = 12, positions = "last",
                components = "residual", spill = FALSE)
stopifnot(identical(tr$neuron, seq_len(m$hidden_size)),
          length(unique(tr$token_pos)) == 1L)
values <- tr$value
actual_source_position <- unique(tr$token_pos)
rm(tr)
# Repeat for the corresponding target/control prompt, storing one matrix row.
```

The complete recorded context has the structure shown above. Hash the exact
UTF-8 prompt bytes without normalizing whitespace/newlines. Pair IDs must match
both matrices in order, and their column names must be every neuron from 1
through the model width. Declare construction, selection and evaluation prompt
hashes in disjoint, nonempty sets **before** fitting. This detects declared exact
text overlap, not semantic paraphrases or undisclosed data.

The default 64 MiB budget includes materialized matrices, metadata, working arrays
and the returned artifact. Limits are 4096 pairs, 65536 neurons, 16384 split rows
and 8 MiB of context. Checksumming streams at most 4096 bytes per write through
one uniquely named temporary file, removed on success, failure or interrupt.
This is not an out-of-core constructor or a bound on all R heap/model/KV memory.
A spilled trace still needs a bounded materialized slice for each matrix row.

## Apply to a compatible model

```{r}
#| eval: false
# destination_record was recorded independently for the actual loaded model.
# It contains sha256, architecture, quantization, hidden_size, layers,
# and engine_revision, exactly as construction context$model.
changed <- llm_apply_direction(m, restored, destination_record, coef = 1)
answer <- llm_generate(changed, held_out_prompt, temperature = 0, seed = 101)
close(changed)
```

The six destination fields must match the artifact; available handle metadata
must also match. The native steering capability and busy/closed checks still
apply. The original handle is unchanged. Negative coefficients reverse the
addition and zero retains existing zero-steering semantics. This is additive
residual steering at every position. The explicit `operator = "project"` mode
uses a separately constructed component artifact, as shown below.
Copying the artifact's record into an unrelated same-width handle does **not**
authenticate that handle. relm does not have a native loaded-weight checksum.

Existing `on_state` coefficient replies can vary the new steering entry during
generation. `llm_timeline()` records the actually applied coefficients, while
`llm_compare()` withholds differences after input histories diverge. Returning a
coefficient to zero cannot erase its earlier effect on KV history. Extracting
`direction$value` and calling `llm_steer()` directly bypasses artifact checks.

## Project a component direction

Residual artifacts use schema 1 and the default `operator = "add"`. A full-width
`mlp_out` or `attn_out` capture produces schema 2; its component is part of the
checksummed identity. Construct it from paired observations at that component.
Changing a residual artifact's label does not make it a component direction.

For this **synthetic arithmetic example only**, reuse the four-coordinate inputs
and declare a synthetic MLP capture. This is not evidence of a real MLP capture
or of model compatibility.

```{r}
mlp_context <- context
mlp_context$capture$component <- "mlp_out"
mlp_direction <- llm_direction(target, control, mlp_context, layer = 2)
stopifnot(identical(attr(mlp_direction, "direction")$schema, "relm_direction/2"),
          identical(attr(direction, "direction")$schema, "relm_direction/1"))

# Unit direction v; a single synthetic component row h.
v <- mlp_direction$value
h <- c(3, 2, -1, 0.5)
project <- function(coef) h - coef * v * sum(v * h)
stopifnot(identical(project(0), h),
          max(abs(project(1) - c(-0.2, 0.4, -1, 0.5))) < 1e-12,
          abs(sum(v * project(1))) < 1e-12,
          abs(sum(v * project(-1)) - 2 * sum(v * h)) < 1e-12)
data.frame(neuron = 1:4, original = h, removed = project(1),
           amplified = project(-1))
```

Projection applies `h - coef * v * sum(v * h)` before the component is added to
the residual. Zero is a no-op, one removes the parallel component within floating
point bounds, and minus one doubles it. Negating `v` leaves the projection
unchanged. It changes activations, not weights, and does not imply better answers.

```{r}
#| eval: false
# real_mlp_direction was built from actual full-width mlp_out observations.
projected <- llm_apply_direction(m, real_mlp_direction, destination_record,
  coef = 1, operator = "project", max_bytes = 64 * 1024^2)
plot(projected, layers = c(1L, 12L, m$layers))
answer <- llm_generate(projected, held_out_prompt, temperature = 0, seed = 101)
close(projected)
```

This increment supports dense Llama attention/MLP output and dense Qwen2 MLP
output on CPU, Metal and the optional single-device Linux CUDA build. CUDA
uses synchronous copies into the budgeted host row; it is not shared GPU memory.
Qwen2 attention, MoE, Windows and other graph/site combinations are not supported
by projection. Component layers may be `1:L`;
residual addition retains `2:L`. Native checks reject unsupported producers and
duplicate `(layer, component)` projections before returning a usable handle.

The application budget includes retained directions, the native row buffer and
compiled ownership/transport storage, plus admitted R materialization. Adding a
projection accounts for existing entries too. Derived steering/ablation retains
the projection and its budget; insufficient budget raises `relm_error_oom`.
Base model/KV memory, separately requested capture/spill and total process RSS
remain outside this projection-specific bound.

Projection coefficients are fixed for a derived handle. Live `on_state` replies
can still update additive steering entries; they cannot update a projection ID.
`llm_timeline()` audits additive live coefficients, so a static projection alone
has no live revision entries. Use generation's live capture for post-edit states:
ordinary tracing and embedding reject intervened handles, and projected image
requests are unsupported. The original handle remains unchanged.

## Evaluate separately from construction

Freeze the layer, corpus and construction method before inspecting selection
results. On selection prompts compare the original model, a zero coefficient,
a fixed coefficient grid and a norm-matched random direction. Require quality
checks as well as a desired effect: a shorter incorrect answer is not an
improvement. Freeze the chosen coefficient before final evaluation; a zero
choice is a legitimate outcome.

For each held-out prompt retain raw text, actual sampled-token IDs, finish reason,
required-answer criteria and failures. Use paired comparisons, dose-response
plots and uncertainty intervals appropriate to the sampling design. A small
hand-written set does not establish broad generalization. An interval conditional
on those tasks is not a claim about all prompts. Do not retune on the final set.

The repository's F6d protocol records 12 construction pairs, six selection tasks,
eight final tasks, fixed quality guards and a zero fallback before model execution.
Its implementation report records actual results and limitations separately;
this vignette's executable example claims only its synthetic arithmetic.

The separate F6e-v1 experiment froze new construction, selection and final data.
After 72 selection settings it locked residual addition at 2 and MLP projection
at 1, then ran all 48 final settings once. On its eight fixed final tasks:

| Setting | Mean characters | Mean tokens | Literal required answer included | Reached 256-token cap |
| --- | ---: | ---: | ---: | ---: |
| Original | 1209.625 | 256 | 6/8 | 8/8 |
| Addition, coefficient 2 | 483.125 | 102.75 | 7/8 | 3/8 |
| Projection, coefficient 1 | 1102.25 | 234.875 | 6/8 | 7/8 |
| Balanced pair-sign random projection | 1205 | 256 | 6/8 | 8/8 |

Projection minus original was -107.375 characters, with a descriptive paired
95% bootstrap interval of [-350.925, 68.3875], conditional on these eight tasks.
The result is modest. Length-limited outputs do not measure complete-answer
brevity, and literal inclusion is not general correctness. Different sites and
directions prevent an isolated operator comparison. The pinned Qwen tokenizer
also reclassified a control-looking token from its stored normal type; its
possible stopping effect is retained as a limitation. These are recorded results,
not computations repeated while rendering this document. The final set is
consumed and must not be used to retune the coefficients.
