--- 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.