Skip to content

Measurement layer M1: Codebook (foundry_codebook, schema helpers, hashing, print, diff) - #3

Merged
farach merged 3 commits into
mainfrom
farach-feature-m1-codebook
Jul 4, 2026
Merged

Measurement layer M1: Codebook (foundry_codebook, schema helpers, hashing, print, diff)#3
farach merged 3 commits into
mainfrom
farach-feature-m1-codebook

Conversation

@farach

@farach farach commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Measurement layer — Milestone M1: Codebook

Implements R/codebook.R per Section 5.1 and the M1 row of Section 11 of SPEC-measurement-layer.md:

  • foundry_codebook() constructor with validated name (slug), version (semver), instructions, schema, examples, UTC created, and a deterministic SHA-256 hash.
  • Schema helpers type_boolean(), type_enum(), type_number(), type_string() wrapping the existing schema_*() constructors.
  • codebook_diff() (unified instruction diff + field-level schema/examples diff + both hashes).
  • print/format methods.
  • Content-addressed hashing over the canonical serialization of (instructions, schema, examples, version).
  • Supporting config: .lintr, DESCRIPTION (digest import), NAMESPACE, NEWS.md, _pkgdown.yml, .Rbuildignore, .gitignore; tests + snapshots.

This PR was opened to host the Section 11.0 step 6 / Section 11.1 fresh-context reviewer pass. The reviewer did not write the code. Findings are posted as review comments, each classified BLOCKING or NON-BLOCKING, with an independent gap-analysis table.

farach and others added 2 commits July 3, 2026 18:05
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

@farach farach left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh-context reviewer pass — Milestone M1 (Codebook)

Per Section 11.0 step 6 and Section 11.1. I did not write this code and reviewed only SPEC-measurement-layer.md and the M1 diff, independently repeating steps 2 (line-by-line), 4 (gap analysis), and 5 (dead-code / scope), plus the M1 acceptance check that hashing is reproducible across sessions (read against Section 5.1, not trusted from tests alone).

Verdict: APPROVE — 0 BLOCKING findings. 5 NON-BLOCKING items below.

Note on mechanics: a true GitHub Approve event is rejected here because the reviewer and the PR author are the same farach account (the spec's fresh-context protocol assumes distinct identities). This review is therefore submitted as a Comment, but the reviewer's verdict is approve: zero blocking items remain. Two NON-BLOCKING items (#4 export scope, #5 milestone isolation) touch spec-discipline rules and warrant Alex's decision, but I am not blocking merge on them.

What I verified independently

  • Hash reproducible across sessions (M1 acceptance): PASS. Fresh --vanilla R process reproduces the committed hash a74264284ddf…929fb2a; created is excluded from the payload (identical content -> identical hash despite differing timestamps); independent recomputation of the Section 5.1 recipe matches. Fixed order is instructions, schema, examples, version as specified.
  • Snapshot + unit tests: PASS. 22/22 pass with NOT_CRAN=true (the 2 snapshot tests skip on CRAN by testthat default — correct hygiene).
  • lintr on R/codebook.R: 0 lints (the file is not in .lintr exclusions; object_usage_linter is off, so I confirmed by hand that all 14 internal helpers are called — no dead code, no TODO/FIXME/browser()).
  • Dependencies: only digest added to Imports (jsonlite already imported); no irr/boot/caret. §8 / §3.5 satisfied.
  • check(): on this Windows box the harness emits a spurious 1 ERROR / 1 WARNING from a locale issue (Setting LC_CTYPE=C.UTF-8 failed) and a quarto -V probe quirk (Unknown command "TMPDIR=…"); the R CMD build DESCRIPTION check is OK, all package checks are OK, and there are 0 notes. No package-level problem is attributable to the M1 diff. Implementer should confirm a fully clean run on their environment (step 3).

Independent gap-analysis (every M1 requirement by spec section)

# Requirement (spec §) Status Evidence
1 Class foundry_codebook; elements name,version,instructions,schema,examples,created,hash in order (§5.1) MET codebook.R 47-61; test-codebook.R 16-19
2 name lowercase slug, hyphens (§5.1) MET regex codebook.R:206; snapshot rejects "Bad Name"
3 version validated semver (§5.1) MET codebook.R:215-227; snapshot rejects "1"
4 instructions chr(1) (§5.1) MET codebook.R:41
5 schema built/coerced via foundry_schema() (§5.1) MET as_foundry_schema codebook.R:42; reuses R/schema.R
6 examples list|NULL, part of hash (§5.1) MET codebook.R:43-45,184
7 created POSIXct UTC (§5.1) MET codebook.R:231-233; test:23-24
8 hash = sha256 over canonical serialization of (instructions,schema,examples,version), fixed order (§5.1) MET codebook.R:179-193
9 Canonical toJSON(auto_unbox,digits=NA,null="null") -> digest sha256 on string; documented; reproducible across sessions (§5.1, M1) MET (doc gap, see comment) fresh-session repro; created excluded (verified)
10 type_* schema helpers, reuse existing constructors (§5.1) MET codebook.R:80-103; test:73-81
11 codebook_diff: unified instr diff + field-level schema/examples diff + both hashes (§5.1) MET codebook.R:121-145; diff snapshot
12 Print: name, version, hash prefix, vars w/ types + allowed values, n examples (§5.1) MET codebook.R:148-169; print snapshot
13 Deps: add digest; jsonlite already imported; no irr/boot/caret (§8,§3.5) MET DESCRIPTION diff
14 New file R/codebook.R only; tests mirror (§4) MET commit touches only R/codebook.R; test-codebook.R
15 No dead code / TODO / commented blocks (§13, §11.0.5) MET 14/14 internal helpers called; grep clean; lintr 0
16 lintr passes on new file (§13, §11.0.3) MET .lintr added; lint(R/codebook.R) = 0
17 NEWS.md entry per milestone (§13) MET NEWS.md M1 line
18 Snapshot tests pass (M1 acceptance) MET 22/22 with NOT_CRAN=true
19 check() clean (M1 acceptance) MET* build/all checks OK, 0 notes; reported ERROR/WARNING are environmental (locale + quarto probe) — implementer to confirm on clean env
20 Exports frozen to §4/§6 (§13, §11.0.5) MET w/ caveat type_* anchored in §5.1 but absent from §4/§6 — spec inconsistency; see comment #4
21 One milestone per PR (§0.3) PARTIAL (NON-BLOCKING) pkgdown "Measurement" section is M6 work — see comment #5

No NOT MET items. No BLOCKING items. The five inline comments are documentation, robustness, test-coverage, and scope-reconciliation improvements. The implementer resolves them; #4 and #5 need Alex's call on spec discipline.

Comment thread R/codebook.R Outdated
digits = NA,
null = "null"
)
digest::digest(as.character(json), algo = "sha256", serialize = FALSE)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NON-BLOCKING (reproducibility — verified, plus one hardening note)

I verified the M1 acceptance criterion "hashes stable across sessions" by reading this serialization against Section 5.1 and by reproducing it in a fresh --vanilla R process:

  • The committed snapshot hash a74264284ddf…929fb2a is reproduced exactly in a clean session.
  • An independent hand-recomputation of the recipe (fixed order instructions, schema, examples, version -> toJSON(auto_unbox=TRUE, digits=NA, null="null") -> digest(serialize=FALSE)) yields the same hash.
  • Building the same codebook twice ~1s apart gives different created but an identical hash, confirming the session-varying timestamp is correctly excluded from the payload.

Two notes on this line:

  1. serialize = FALSE is the right choice and is actually stronger than the literal Section 5.1 recipe (which omits serialize and would otherwise hash R's serialization envelope). Good.
  2. The JSON string is not enc2utf8()-normalised before hashing. For non-ASCII instructions/examples, the bytes handed to sha256 can depend on the session's string encoding/locale, which weakens "reproducible across sessions" on differently-configured machines. Consider digest(enc2utf8(as.character(json)), algo = "sha256", serialize = FALSE).

Comment thread R/codebook.R
foundry_codebook_hash <- function(instructions, schema, examples, version) {
payload <- list(
instructions = instructions,
schema = foundry_preserve_schema_arrays(schema),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NON-BLOCKING (documentation fidelity of the canonical form)

The canonical serialization documented in the roxygen (and in Section 5.1) is toJSON(auto_unbox=TRUE, digits=NA, null="null") of (instructions, schema, examples, version). In practice the schema element is first passed through foundry_preserve_schema_arrays() so that single-element arrays (a one-value enum, or a one-field required) are not unboxed to scalars.

That step is correct and necessary — but it is undocumented as part of the "canonical" recipe. An independent reimplementation following the documented recipe literally would compute a different hash for any codebook whose schema contains a length-1 array. Please document the array-preservation normalization as part of the canonical form (and ideally reflect it in Section 5.1), since M1's whole value proposition is a hash others can reproduce.

examples = list(list(text = "Write code", label = "yes"))
)

canonical <- jsonlite::toJSON(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NON-BLOCKING (test coverage gap)

This hand-recomputation does not apply foundry_preserve_schema_arrays() and uses a two-value enum, so auto_unbox leaves the array intact and the assertion passes without exercising the array-preservation path. That path — a single-element enum/required — is exactly where the implementation's canonical form and the documented recipe diverge (see the note on R/codebook.R:182). Suggest adding a case with a one-value enum asserting the hashed payload keeps ["x"] as an array, to lock in cross-session / cross-reimplementation stability.

Comment thread R/codebook.R

#' @rdname codebook_schema_helpers
#' @export
type_boolean <- function(desc = NULL) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NON-BLOCKING (export-scope reconciliation — decision for Alex)

type_boolean / type_enum / type_number / type_string are exported. They are explicitly requested by Section 5.1 ("Provide helpers type_boolean(desc), …"), so they do trace to a numbered section per rule 1b, and they are required for the documented foundry_schema(field = type_*()) usage.

However, Section 13 and Section 11.0 step 5 freeze the exported surface to "Sections 4 and 6", and Section 4's R/codebook.R line lists only foundry_codebook(), foundry_schema(), codebook_diff(), print/format, hashing — not the type_* helpers. This is a spec internal inconsistency, not an implementation defect. Recommend Alex amend Section 4 to list the type_* helpers so the frozen-surface rule is self-consistent. Not blocking: I am treating Section 5.1 as a valid anchor.

Comment thread _pkgdown.yml Outdated
aria-label: GitHub repository

reference:
- title: Measurement

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NON-BLOCKING (milestone isolation — Section 0 rule 3)

This adds the pkgdown "Measurement" reference section. Per Section 11, pkgdown updates are an M6 deliverable ("M6 … README and pkgdown updates"), and Section 0 rule 3 says do not combine milestones. R CMD check does not read _pkgdown.yml, so this is not needed for M1's "check() clean". The entries themselves are correct. Suggest either deferring this block to M6 or getting Alex's explicit OK to register reference entries as functions land. (A strict reading of Section 0.3 could treat this as blocking; I classify it NON-BLOCKING because it does not affect any M1 acceptance criterion.)

Normalize codebook hash payloads to UTF-8 before digesting, document schema
array preservation in the hash recipe, add single-value enum hash coverage, and
move M1 codebook reference entries out of the future Measurement pkgdown section.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@farach

farach commented Jul 4, 2026

Copy link
Copy Markdown
Owner Author

Addressed the fresh-context review items in 408552b:

  • Normalized the canonical hash payload with enc2utf8() before digest::digest().
  • Documented that schema arrays are preserved before canonical serialization, matching the actual hash recipe.
  • Added single-element enum hash coverage so the array-preservation path is tested.
  • Removed the new top-level pkgdown Measurement section and moved M1 topics into existing reference sections to avoid pulling M6 docs scope forward.
  • Left type_*() exported for M1 because Section 5.1 explicitly names those helpers; the Section 4/6/13 export-surface tension remains a spec reconciliation item rather than a code change.

Verification after the fixes:

  • lintr::lint_package() — no lints
  • pkgdown::check_pkgdown() — no problems
  • testthat::test_local(reporter = "summary") with live API credentials suppressed — completed with expected integration skips
  • devtools::check(args = "--no-manual", error_on = "error") with blank user Renviron/Profile and live API credentials suppressed — 0 errors | 0 warnings | 0 notes

@farach
farach merged commit 8b10159 into main Jul 4, 2026
6 checks passed
@farach
farach deleted the farach-feature-m1-codebook branch July 4, 2026 22:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant