diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a14e15a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Force LF line endings for text formats this package's own tests and +# reproducibility guarantees depend on being byte-stable across platforms. +# Without this, Windows checkouts with core.autocrlf=true silently convert +# LF to CRLF on checkout, which fails the conformance goldens' "LF line +# endings, no CR byte" contract (inst/conformance/README.md) even though +# nothing about the data changed. +*.csv text eol=lf +*.nt text eol=lf +*.json text eol=lf +*.R text eol=lf +*.Rd text eol=lf +*.md text eol=lf +*.yml text eol=lf diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 86d0f43..967ce36 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -22,7 +22,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: ['3.12', '3.13', '3.14'] + python-version: ['3.10', '3.11', '3.12', '3.13'] include: - {os: windows-latest, python-version: '3.12'} - {os: macos-latest, python-version: '3.12'} diff --git a/AGENTS.md b/AGENTS.md index a92598e..bac58c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,10 @@ reference index. R package providing a reproducible JSON-LD + SPARQL pipeline for analyzing cybersecurity workforce competency frameworks (NICE, DCWF, SFIA, ENISA ECSF, CyQUAL, the Canadian Cyber Security Skills Framework, -Singapore's OTCCF) and pedagogical/learning-standards frameworks -(Cyber.org K-12, CSTA K-12 CS, ACM/IEEE CSEC2017, JRC DigComp 2.2). The +Singapore's OTCCF, the Saudi Cybersecurity Workforce Framework) and +pedagogical/learning-standards frameworks and bodies of knowledge +(Cyber.org K-12, CSTA K-12 CS in its 2017 and 2026 editions, ACM/IEEE +CSEC2017, JRC DigComp 3.0, the UK's CyBOK). The intellectual contribution is a framework-agnostic schema (`cybed:`) that lets one query operate across heterogeneous frameworks. The implementation enforces the schema and provides analytical helpers. @@ -58,7 +60,7 @@ repo. Stage them under `data/raw//` per ## Test, document, build ```sh -# Run the test suite (roughly 550 tests) +# Run the test suite Rscript -e "devtools::test()" # Regenerate man/ + NAMESPACE after roxygen comment changes diff --git a/CITATION.cff b/CITATION.cff index 58c7180..a3aa5ec 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,8 +1,8 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below." type: software -title: "cybedtools: An R Package for Reproducible Analysis of Cybersecurity Workforce and Learning Frameworks" -version: 0.3.1 +title: "cybedtools: A Package for Reproducible Analysis of Cybersecurity Workforce and Learning Frameworks" +version: 0.4.0 # Zenodo concept DOI: the parent identifier that resolves to the latest # released version (currently 10.5281/zenodo.20076116, verified against # the Zenodo project's "Cite all versions" link). This DOI does not @@ -36,12 +36,13 @@ keywords: abstract: >- cybedtools provides a reproducible R pipeline for ingesting, semantically representing (via a two-tier JSON-LD schema), and querying (via SPARQL) - eleven major cybersecurity-related competency and learning-standards - frameworks: NICE, DCWF, SFIA, ENISA ECSF, Cyber.org K-12, CSTA K-12 CS, - ACM/IEEE CSEC2017, DigComp 2.2, CyQUAL, CCSSF, and OTCCF. The cybed: - vocabulary uses + cybersecurity-related competency and learning-standards frameworks, + including NICE, DCWF, SFIA, ENISA ECSF, Cyber.org K-12, CSTA K-12 CS, + ACM/IEEE CSEC2017, DigComp 3.0, CyQUAL, CCSSF, and OTCCF. See + framework_summary() in the package for the current list and counts. The + cybed: vocabulary uses cybed:OrganizingUnit as the cross-framework abstract for parent units and cybed:Role as a workforce-restricted subtype, with cybed:Subpoint and cybed:Example separating framework-as-specified enumerations from - pedagogical-scaffolding examples. Includes a six-layer data-integrity + pedagogical-scaffolding examples. Includes a layered data-integrity verification rig and per-framework provenance manifests. diff --git a/DESCRIPTION b/DESCRIPTION index b8d946c..1c5ed3f 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,8 +1,8 @@ Package: cybedtools Type: Package -Title: Computational Tools for Analyzing Cybersecurity Workforce and - Learning Frameworks -Version: 0.3.1 +Title: A Package for Reproducible Analysis of Cybersecurity Workforce + and Learning Frameworks +Version: 0.4.0 Authors@R: person( "Ryan", "Straight", email = "ryanstraight@arizona.edu", @@ -14,7 +14,7 @@ Description: A reproducible pipeline for ingesting, representing, and 'DCWF', 'SFIA', 'ENISA ECSF', 'CyQUAL', 'CCSSF', 'OTCCF') and cybersecurity-related pedagogical frameworks ('Cyber.org K-12', 'CSTA K-12 CS', 'ACM/IEEE CSEC2017', - 'DigComp 2.2'). Provides a framework-agnostic 'JSON-LD' schema + 'DigComp 3.0'). Provides a framework-agnostic 'JSON-LD' schema ('cybed:') and 'SPARQL' query patterns for cross-framework analysis. License: MIT + file LICENSE Encoding: UTF-8 @@ -27,18 +27,19 @@ Imports: dplyr (>= 1.1.0), purrr, tibble, + tidyr, jsonlite, glue, here, rdflib, rlang, - lifecycle + lifecycle, + digest Suggests: readxl, readr, yaml, stringr, - digest, janitor, DBI, RSQLite, diff --git a/LICENSING.md b/LICENSING.md index 94f5516..9ebb183 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -14,7 +14,7 @@ If you are using cybedtools for academic research: If you are integrating cybedtools into a commercial product: - The MIT license on the code accommodates this. -- Framework content is mixed. Some frameworks (NICE, DCWF) are public domain. Others (SFIA, Cyber.org K-12, CSTA, ACM/IEEE, OTCCF) impose non-commercial or attribution constraints. Two (CyQUAL, OTCCF) are included by written permission of their stewards, and CCSSF is referenced as the Canadian Centre for Cyber Security asked. The terms given to cybedtools do not pass to you. Read the per-framework licenses in [`docs/framework-data-sources.md`](docs/framework-data-sources.md) before redistributing framework text. **It is incumbent upon you to obtain proper licensing**. +- Framework content is mixed. Some frameworks (NICE, DCWF) are public domain. Others (SFIA, Cyber.org K-12, CSTA, ACM/IEEE, OTCCF) impose non-commercial or attribution constraints. Three (CyQUAL, OTCCF, CCSSF) are included by written permission of their stewards. CCSSF's credit reads "Copyright Government of Canada. Used with permission of the Canadian Centre for Cyber Security." The terms given to cybedtools do not pass to you. Read the per-framework licenses in [`docs/framework-data-sources.md`](docs/framework-data-sources.md) before redistributing framework text. **It is incumbent upon you to obtain proper licensing**. ## Scope of the MIT license @@ -26,9 +26,12 @@ This license does NOT extend to: - **SFIA.** All use of SFIA is under licence from the SFIA Foundation, and the licence covers structure as well as text. The Foundation's General Terms and Conditions define the protected material as "The concept, content and structure of SFIA along with content from the SFIA website" and name "a skills database containing SFIA information" as a royalty-bearing product, so there is no structure-versus-text carve-out. Free tiers cover personal use and single-country corporate internal use. Redistribution and sub-licensing are prohibited at every tier. The third-party extract cybedtools reads carries no licence file and holds no SFIA licence it could pass on, and it does contain full skill descriptions, guidance notes and per-level descriptors. The SFIA portion of cybedtools is local-analysis-only: nothing SFIA beyond aggregate counts is published, and anyone staging SFIA needs their own licence. - **Cyber.org K-12.** CC BY-NC 4.0 (attribution, non-commercial), with a separate unconditional grant on the same page: "Authorization to reproduce this report in whole or in part is granted." - **CSTA.** CC BY-NC-SA 4.0 (attribution, non-commercial, share-alike). The label is read from CSTA's 2017-edition publication page, because the ingested XLSX carries no licence statement. + - **CSTA 2026.** CC BY-NC-SA 4.0 (attribution, non-commercial, share-alike), read from the 2026 standards document's own licence page. The same share-alike obligation applies. + - **SCyWF.** No licence notice on the document. Used by written permission of the National Cybersecurity Authority (NCA), 2026-09-20, on three conditions. NCA is cited as issuing body and source with a link to https://nca.gov.sa/en/pages/scywf.html. Everything taken from the document is carried verbatim. Everything cybedtools derives is marked as derived rather than presented as NCA content. - **ECSF.** The Role Profiles report PDF is CC BY 4.0 by its own notice. The JSON and XLSX that cybedtools ingests carry no notice of any kind, and rest instead on ENISA's site-wide notice: "Reproduction of ENISA material published on this website is authorized, provided the source is acknowledged, unless it is stated otherwise." - **CSEC2017.** "Copyright © 2017 by ACM, IEEE, AIS, IFIP / ALL RIGHTS RESERVED", with permission granted only "to use these curricular guidelines for the development of educational materials and programs. Other use requires specific permission." cybedtools publishes Knowledge Area names, counts and alignment scores, and no statement text. A permission request to ACM is being prepared. - - **DigComp 2.2.** CC BY 4.0 by the source PDF's own imprint page, with a prescribed citation carried in `docs/framework-data-sources.md`. + - **DigComp 3.0.** CC BY 4.0, verified three ways against the official data supplement's own `copyright.txt`, the JRC Data Catalogue record, and the DigComp 3.0 resources page, with a prescribed citation carried in `docs/framework-data-sources.md`. Replaces DigComp 2.2 in place. + - **CyBOK.** Open Government Licence v3.0, Crown Copyright NCSC 2021, read from the Introduction to CyBOK's own copyright page. The attribution CyBOK prescribes is carried verbatim: "CyBOK © Crown Copyright, The National Cyber Security Centre 2021, licensed under the Open Government Licence: http://www.nationalarchives.gov.uk/doc/open-government-licence/." - **NICE.** A US Government work, not subject to copyright in the United States under 17 U.S.C. 105. NIST reserves foreign rights and grants them back: "foreign rights are reserved. To the extent NIST may assert rights outside of the United States, the public is granted the non-exclusive, perpetual, paid-up, royalty-free, worldwide right to reprint works in all formats including print, electronically, and online, and in all subsequent editions, and derivative works." - **DCWF.** A US Government work prepared by DoD personnel, so not subject to US copyright under 17 U.S.C. 105. The v5.1 workbook carries no distribution statement, the DoD download pages now require authentication so none could be read, and nine work roles are tagged "(CUI)" in the workbook's role index. Review those sheets before any public deposit of DCWF-derived content. - **Framework analyses** produced by running this pipeline on framework source text. Derivative analytical outputs (code frequencies, cross-framework mappings) are generally safe to publish with attribution, but specific licensing turns on the source framework. @@ -37,7 +40,7 @@ When redistributing or building on this toolkit, respect the upstream framework ## Frameworks included by steward permission -Two frameworks are in cybedtools because their stewards said yes in writing, and the Canadian framework is here referenced as its steward asked. The terms differ, and none of them is as broad as MIT. Full detail, including how to obtain each source, is in [`docs/framework-data-sources.md`](docs/framework-data-sources.md). +Three frameworks are in cybedtools because their stewards said yes in writing. The terms differ, and none of them is as broad as MIT. Full detail, including how to obtain each source, is in [`docs/framework-data-sources.md`](docs/framework-data-sources.md). **OTCCF, Cyber Security Agency of Singapore.** Derived from the Operational Technology Cybersecurity Competency Framework (OTCCF), published by the Cyber Security Agency of Singapore (CSA). Available at: https://www.csa.gov.sg/resources/publications/operational-technology-cybersecurity-competency-framework--otccf-/ @@ -45,7 +48,7 @@ The document names Mercer Singapore as a joint developer alongside CSA, with Ski CSA's permission is for referencing and integrating the OTCCF's structure, for non-commercial, academic and research purposes, and it requires that the ingestion scripts and structural mappings not misrepresent or alter the intent of the OTCCF's job roles, skills, or competency mappings as published by CSA. cybedtools makes public only the OTCCF's structure (roles, skill titles, categories, proficiency levels, role-to-skill mappings) and not its statement text. Eight of its thirty skills and all of its Critical Core Skills originate with SkillsFuture Singapore, and the graph marks them. Commercial use of the OTCCF portion is outside what CSA granted. -**CCSSF, Canadian Centre for Cyber Security.** Canadian Centre for Cyber Security, The Canadian Cyber Security Skills Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada. Referenced as the Canadian Centre for Cyber Security asked. The Centre asks that its material be referenced when used. +**CCSSF, Canadian Centre for Cyber Security.** Canadian Centre for Cyber Security, The Canadian Cyber Security Skills Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada. Used with permission of the Canadian Centre for Cyber Security, granted 2026-09-21 for full-text publication with attribution. **CyQUAL, Masaryk University.** CyQUAL, the Czech national cybersecurity qualifications framework, developed at Masaryk University. Open data, version 1.2.0, https://platform.cyqual.cz/. Published as open data. Attribution to CyQUAL and to Masaryk University is required. diff --git a/NAMESPACE b/NAMESPACE index 030026c..9415e92 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -13,6 +13,7 @@ export(build_role_element_node) export(build_role_node) export(build_subpoint_node) export(build_unit_relation_node) +export(cybed_fetch) export(cybed_license) export(element_framework_bindings) export(element_text) @@ -20,10 +21,12 @@ export(example_framework_bindings) export(expand_with_subpoints) export(extend_role_element_ids) export(framework_metadata) +export(framework_similarity) export(graph_identity_violations) export(graph_invariant_counts) export(load_combined_ntriples_graph) export(load_combined_rdf_graph) +export(load_graph) export(load_single_framework_graph) export(load_unified_rdf_graph) export(make_demo_graph) @@ -35,6 +38,8 @@ export(role_framework_bindings) export(sparql_pairs) export(sparql_subjects) export(subpoint_framework_bindings) +export(unit_element_bindings) +export(unit_relation_bindings) export(validate_jsonld_node) export(write_jsonld_document) importFrom(lifecycle,deprecated) diff --git a/NEWS.md b/NEWS.md index 4811303..87eae0e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,37 @@ -# cybedtools 0.3.1 +# cybedtools 0.4.0 + +## New framework + +- **CyBOK v1.1.0**, the Cyber Security Body of Knowledge (July 2021) published by the National Cyber Security Centre, joins as a fourteenth framework with the slug `cybok`, the framework id `cybok-v1.1.0` and the prefix `cybok:`, under the Open Government Licence v3.0 with CyBOK's prescribed attribution. CyBOK publishes PDFs only, so the ingest reads the Introduction, the 21 Knowledge Trees and the A-to-Z Indicative Material index, each staged with its URL, its own version and its SHA256. 21 Knowledge Areas (`cybok:KnowledgeArea`, `cybed:OrganizingUnit`, never `cybed:Role`) carry their category, one of 5, as `cybok:category` and their own document version as `schema:version`. The 119 Topics of the trees are `cybok:Topic` elements, and the 477 Indicative Material nodes drawn under them are `cybed:Subpoint` children typed `cybok:IndicativeMaterial`. Which Topic a node hangs from is read from the tree drawing, not from node position. Every carried string is looked up in an independent extraction of its PDF, 622 of 622 found. The A-to-Z index is a cross-check only: 366 of its 482 Knowledge Area rows resolve to a Topic and term in the trees, and every other row is reported in `tables/a-to-z-resolution.csv`, not corrected. The staged SFIA and ECSF crosswalks resolve to KA acronyms in full and are not wired into the graph yet. The sub-point parser is off. Held from data release 2026.09.1, which was cut before it joined. The other thirteen frameworks' N-Triples are byte-identical. +- `framework_summary` and `framework_licenses` gain a `cybok-v1.1.0` row. Every value in the thirteen existing rows is unchanged. +- `scripts/015-verify-ingestion.R` also fails hard when a CyBOK carried string is missing from its check text. +- **SCyWF 1.5**, the Saudi Cybersecurity Workforce Framework (SCyWF – 1.5: 2026) issued by the National Cybersecurity Authority (NCA), joins as a thirteenth framework with the slug `scywf`, the framework id `scywf-1.5` and the prefix `scywf:`, by NCA's written permission of 2026-09-20. 40 job roles (`scywf:JobRole`, `cybed:Role`) link by `cybed:hasElement` to the 1,419 Task, Knowledge and Skill statements printed on their cards (4,794 links, every one resolving). Codes, titles and statement text are carried verbatim, and the ingest proves it: every carried string is looked up in an independent extraction of the PDF, 3,290 of 3,290 found. 24 competency areas, 12 specialty areas and 5 categories are organizing units that are not roles. The edges from role to specialty area and from specialty area to category are cybedtools-derived and sit on their own predicates, `scywf:inSpecialtyArea` and `scywf:inCategory`. SCyWF reuses NICE-shaped codes: 296 are shared with NICE v2.2.0 and the text differs under every one, so the two are never joined on a bare code. The sub-point parser is off. The Career Progression companion document is staged and not ingested yet. Held from data release 2026.09.1, which was cut before it joined. The other twelve frameworks' N-Triples are byte-identical. +- `framework_summary` and `framework_licenses` gain a `scywf-1.5` row. The rebuild also brings `digcomp-3.0` in line with its parser being disabled: its `subpoint_count` goes from 97 to 0 and its with-examples columns follow. That change was committed earlier without a rebuild, and the summary build's regression guard now names it as an acknowledged change. Every other existing value is unchanged. +- `scripts/015-verify-ingestion.R` fails hard when a framework carried on a verbatim condition (SCyWF) has any carried string missing from its check text or any role-card code without a statement. +- **CSTA PK-12 CS (2026)**, the 2026 CSTA PK-12 Computer Science Standards (DOI 10.1145/3820482), joins as a twelfth framework with the slug `csta-2026` and the prefix `csta2026:`. It sits alongside the 2017 edition rather than replacing it. The two share no identifier scheme, and `csta-2017` comes out byte-identical. 331 standards, keyed by CSTA's codes verbatim, are grouped into 53 `csta2026:StandardGroup` units: level x concept for the 196 foundational standards and tier x specialty area for the 135 specialty standards. Units are built from the pairs that occur, so X+CS, published at Specialty I only, has one unit. Every standard carries `csta2026:tier`, `csta2026:subconcept` and, in the specialty tier, `csta2026:specialtyArea`, so the 27 Cybersecurity (`CYB`) standards can be selected directly. Boundary statements are `csta2026:boundaryStatement` literals on the standard. CSTA's 652 implementation examples are `cybed:Example` nodes reached through `cybed:hasExample`, as the 2017 clarifications are. Practices, dispositions and progressions are not modelled yet. Licence CC BY-NC-SA 4.0, read from the document's own licence page. The framework is held from data release 2026.09.1, which was cut before it joined. +- `framework_summary` and `framework_licenses` gain a `csta-2026` row. Every value in the eleven existing rows is unchanged. +- The release export's licence lookup no longer treats `csta-2026` as a version of `csta`. A slug that is a framework in its own right is excluded from another slug's version-suffix match. +- `scripts/015-verify-ingestion.R` checks the SHA256 of every file a manifest lists under `source.files`, not only a single `retrieval.file_sha256`. +- The sub-point parser is disabled for `csta-2026`, the same treatment OTCCF gets: CSTA's standards are published statements, and a parser-split fragment would be a unit the package invented rather than one CSTA printed. Its 45 previously-parsed Subpoints drop out; parent elements (331) and implementation examples (652) are unaffected. -## Fixes +## Framework upgrade + +- **DigComp 3.0 replaces DigComp 2.2 in place** (owner decision, 2026-08-21). The versionless IRIs `digcomp:AREA-*` and `digcomp:COMP-x.y` survive unchanged, but the framework node is now `digcomp-3.0` and every element's text changed (4 of 5 area names, 13 of 21 competence names revised; all descriptions rewritten). Source of record is the official JSON-LD data supplement (JRC144121), not a PDF scrape, hash-anchored and verified at ingest. The 7 official errata to the Learning Outcomes are applied (523 -> 522; see `data/raw/digcomp/v3.0/errata.csv`). Structurally, elements move down from the 21 competences to the 362 Competence Statements, so competences become a second organizing-unit tier (`digcomp:Competence`) alongside the 5 areas (`digcomp:CompetenceArea`) -- 26 organizing units total, both flat, neither a `cybed:Role` (DigComp is a citizen self-assessment instrument). The 522 Learning Outcomes attach to their Competence as `cybed:Example` via `cybed:hasExample`, never `cybed:hasElement` (no source-provided link to an individual statement). Proficiency levels (8-level descriptors with 4-level and CEFR-style 6-level crosswalks) and a 126-term glossary are staged but not yet emitted into the graph. DigComp 2.2's raw source stays archived at `data/raw/digcomp/v2.2/`. No other framework's output changed. + +## Licensing + +- **CCSSF is now full text, with attribution** (owner decision, 2026-09-21). The Canadian Centre for Cyber Security gave cybedtools written permission for full-text publication with attribution, superseding the 2026-09-19 reading of its earlier reply as a reference-only instruction rather than a grant. `docs/framework-invariants.yml`'s `ccssf` policy moves from `structure_only` to `full_with_attribution`; `framework_licenses`' `ccssf-2022` row now has `granted = TRUE` and `license_short = "Government of Canada, with permission"`. Every remaining "Referenced as the Canadian Centre for Cyber Security asked" wording (`creditText`, `license`, ingestion comments, `docs/ingestion-summary.md`, `docs/framework-data-sources.md`, `LICENSING.md`, `README`) now reads "Copyright Government of Canada. Used with permission of the Canadian Centre for Cyber Security." No other framework's attribution changed. Data release 2026.09.1 is not revised; it was cut before this permission was received and still holds CCSSF for steward confirmation. + +## API (0.4.0, Python parity) + +- Every `*_bindings()` helper and `framework_metadata()` gain a `framework_slug` column (a stable join key, e.g. `"nice-v2"`, derived from the framework IRI's own local part). Existing columns and row order are unchanged. +- New exported `unit_element_bindings(rdf)`, identical to the old `role_element_bindings()` output plus `framework_slug`. `role_element_bindings()` is now a deprecated alias that calls `unit_element_bindings()` and warns. It will be removed in a future minor version. +- New exported `unit_relation_bindings(rdf)`: one row per `cybed:UnitRelation` node, columns `from_unit`, `relation`, `to_unit`, `framework_slug`. +- New exported `framework_similarity(rdf, from, to, n = 5)`: the one public entry point for cross-framework unit-text similarity, built on the existing internal tokenizer, Jaccard scorer, and ranking helpers (unchanged math). Compares each `from`-framework organizing unit's full document (its own name and text, plus every child element's text) against every `to`-framework unit, keeping the top `n` matches with a `strength` label. Reproduces the "full-document" pass used by the concordance NICE-vs-ECSF alignment script, generalized to any two frameworks. +- New exported `cybed_fetch(frameworks = NULL, version = NULL)` and `load_graph(frameworks = NULL, version = NULL)`: download and SHA-256-verify per-framework release files into `tools::R_user_dir("cybedtools", "cache")`, never any other location, and parse them into an rdf graph. The release location is configurable via the `cybedtools.release_url` option or the `CYBEDTOOLS_RELEASE_URL` environment variable, so tests (and air-gapped mirrors) can point at a local `file://` release. The default base URL points at this repository's GitHub release assets. +- New `inst/conformance/` directory: a hand-authored fixture graph (`fixture.nt`), a mock release directory for `cybed_fetch()`/`load_graph()` tests, and one golden CSV per public query function. This is the exact parity contract a Python port is built against; see `inst/conformance/README.md` for the format (encoding, sort keys, rounding, NA handling). + +# cybedtools 0.3.1 - **CCSSF attribution no longer claims permission.** The Canadian Centre for Cyber Security's reply asked that its material be referenced when used; it did not grant permission. Every remaining "used with permission" wording for CCSSF (`creditText`, `license`, ingestion comments, `docs/ingestion-summary.md`, `docs/framework-invariants.yml`, and the framework's provenance manifest) now reads "Copyright Government of Canada. Referenced as the Canadian Centre for Cyber Security asked." No other framework's attribution changed, and the assembled graph is byte-identical for all ten other frameworks; only the two CCSSF `creditText`/`license` triples differ. @@ -46,6 +77,7 @@ Eleven frameworks, up from eight. Two national frameworks join by written permis - New `unit_relation_count`. - The build now stops, naming the framework, if the graph and the display table disagree about which frameworks exist. Before, a framework missing from the table was dropped without a message. - Documentation now says exactly what `element_count_strict` counts (parent elements only) and notes that `docs/framework-invariants.yml` uses "strict" differently. +- **Corrected 2026-08-14**: `element_count_strict` previously subtracted only `example_count`, so any framework with nonzero Subpoints carried an inflated "strict" value that silently included non-parent content (NICE +4, SFIA +158, ECSF +16, CSTA +20, CSEC2017 +2). That is what produced the NICE 2,115-vs-NIST's-2,111 discrepancy caught in the Concordance manuscript audit. ## Framework data diff --git a/R/cybed-fetch.R b/R/cybed-fetch.R new file mode 100644 index 0000000..86c18bf --- /dev/null +++ b/R/cybed-fetch.R @@ -0,0 +1,251 @@ +# R/cybed-fetch.R +# +# User-facing data loader for the public per-framework data release +# (scripts/030-export-release.R's output). cybed_fetch() downloads and +# hash-verifies release files into the CRAN-mandated user cache directory +# and never writes anywhere else. load_graph() turns cached files into an +# rdf object. + +#' Resolve the base URL a release is fetched from +#' +#' Resolution order: the `cybedtools.release_url` option, then the +#' `CYBEDTOOLS_RELEASE_URL` environment variable, then a built-in default +#' pointing at this repository's GitHub release assets. Set either the +#' option or the environment variable to a `file://` URL (a local mock +#' release directory) in tests, so no test call ever reaches the network. +#' +#' The default carries a literal `{version}` placeholder, because a GitHub +#' release's assets sit directly under its own tag +#' (`releases/download/data-v/`, no further version segment) while +#' every other supported layout (a mirror, or a mock release directory used +#' in tests) nests each version in its own subdirectory below one fixed +#' base. `cybed_release_url()` is what tells the two apart. +#' +#' @return Character scalar, a URL with no trailing slash. +#' @noRd +cybed_release_base_url <- function() { + opt <- getOption("cybedtools.release_url") + if (!is.null(opt) && nzchar(opt)) { + return(sub("/+$", "", opt)) + } + env <- Sys.getenv("CYBEDTOOLS_RELEASE_URL", unset = NA_character_) + if (!is.na(env) && nzchar(env)) { + return(sub("/+$", "", env)) + } + "https://github.com/ryanstraight/cybedtools/releases/download/data-v{version}" +} + +#' Build the URL of one release file (the manifest or a framework's `.nt.gz`) +#' +#' A GitHub release's assets are tagged `data-v` and sit directly +#' under that tag with no further version segment, so a base URL carrying +#' the `{version}` placeholder has the version substituted in place rather +#' than appended. Every other base URL (a mirror, or a mock release +#' directory such as `inst/conformance/mock-release//`) nests each +#' version below the fixed base, as before. +#' +#' @param base_url Character scalar, from `cybed_release_base_url()`. +#' @param version Character scalar release version (or `"latest"`). +#' @param filename Character scalar, e.g. `"manifest.json"` or `".nt.gz"`. +#' @return Character scalar URL. +#' @noRd +cybed_release_url <- function(base_url, version, filename) { + if (grepl("{version}", base_url, fixed = TRUE)) { + base_url <- sub("{version}", version, base_url, fixed = TRUE) + return(paste0(base_url, "/", filename)) + } + paste0(base_url, "/", version, "/", filename) +} + +#' The package's cache directory +#' +#' @return Character scalar path, created if it does not yet exist. +#' @noRd +cybed_cache_dir <- function() { + dir <- tools::R_user_dir("cybedtools", which = "cache") + if (!dir.exists(dir)) { + dir.create(dir, recursive = TRUE, showWarnings = FALSE) + } + dir +} + +#' Read a manifest.json, local or remote, without ever writing outside the cache +#' +#' @param base_url Character scalar. +#' @param version Character scalar, a release version segment (or `"latest"`). +#' @return A list, the parsed manifest. +#' @noRd +cybed_read_manifest <- function(base_url, version) { + url <- cybed_release_url(base_url, version, "manifest.json") + tmp <- tempfile(fileext = ".json") + on.exit(unlink(tmp), add = TRUE) + cybed_download(url, tmp) + jsonlite::fromJSON(tmp, simplifyVector = FALSE) +} + +#' Download one URL to one destination path, uniformly for http(s):// and file:// +#' +#' @noRd +cybed_download <- function(url, destfile) { + result <- tryCatch( + utils::download.file(url, destfile, mode = "wb", quiet = TRUE), + error = function(cnd) cnd + ) + if (inherits(result, "condition") || !file.exists(destfile) || + file.size(destfile) == 0) { + rlang::abort( + c( + "Could not download a release file.", + "x" = paste0("URL: ", url, "."), + "i" = paste0( + "Set `options(cybedtools.release_url = ...)` or the ", + "CYBEDTOOLS_RELEASE_URL environment variable to point at a ", + "reachable release (a `file://` URL works for local testing)." + ) + ), + class = "cybedtools_download_failed", + url = url + ) + } + invisible(destfile) +} + +#' Download and hash-verify per-framework release files into the user cache +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' Downloads the release manifest and the named frameworks' `.nt.gz` files +#' from the configured release location (see the release URL resolution rules documented below), +#' verifies each file's SHA-256 against the hash the manifest declares, and +#' writes only into `tools::R_user_dir("cybedtools", "cache")` -- never any +#' other location on disk, per CRAN policy. A file whose downloaded bytes +#' do not match its manifest hash is deleted and the call aborts with class +#' `cybedtools_hash_mismatch` rather than leaving a corrupt cache entry. +#' +#' Already-cached files whose hash still matches the manifest are not +#' re-downloaded. +#' +#' @param frameworks Character vector of framework slugs to fetch (as +#' carried by [framework_summary]`$framework_slug`, e.g. `"nice-v2"`), or +#' `NULL` (the default) for every framework the release manifest ships. +#' @param version Character scalar release version (e.g. `"1.0.0"`), or +#' `NULL` (the default) for the release location's `"latest"` alias. +#' @return Invisibly, a tibble with one row per fetched framework: columns +#' `framework_slug`, `path` (the cached file's local path), +#' `sha256_verified` (logical, always `TRUE` on return -- a mismatch +#' aborts instead of returning `FALSE`). +#' @family data loading +#' @export +#' @examples +#' \donttest{ +#' # Requires network access (or a `cybedtools.release_url` override +#' # pointing at a local mock release for testing). +#' tryCatch( +#' cybed_fetch(frameworks = "nice-v2"), +#' cybedtools_download_failed = function(cnd) message("No network: ", conditionMessage(cnd)) +#' ) +#' } +cybed_fetch <- function(frameworks = NULL, version = NULL) { + base_url <- cybed_release_base_url() + version <- if (is.null(version)) "latest" else version + manifest <- cybed_read_manifest(base_url, version) + + files <- manifest$files + manifest_slugs <- vapply(files, function(x) x$slug, character(1)) + + if (is.null(frameworks)) { + frameworks <- manifest_slugs + } + unknown <- setdiff(frameworks, manifest_slugs) + if (length(unknown)) { + rlang::abort( + c( + "Unknown framework slug(s) for this release.", + "x" = paste0("Unknown: ", paste(unknown, collapse = ", "), "."), + "i" = paste0("Known slugs: ", paste(manifest_slugs, collapse = ", "), ".") + ), + class = "cybedtools_framework_not_found" + ) + } + + cache_dir <- file.path(cybed_cache_dir(), version) + if (!dir.exists(cache_dir)) { + dir.create(cache_dir, recursive = TRUE, showWarnings = FALSE) + } + + rows <- lapply(frameworks, function(slug) { + entry <- files[[which(manifest_slugs == slug)]] + dest <- file.path(cache_dir, entry$file) + + needs_download <- !file.exists(dest) || + !identical(digest::digest(dest, algo = "sha256", file = TRUE), entry$sha256) + + if (needs_download) { + url <- cybed_release_url(base_url, version, entry$file) + cybed_download(url, dest) + actual <- digest::digest(dest, algo = "sha256", file = TRUE) + if (!identical(actual, entry$sha256)) { + unlink(dest) + rlang::abort( + c( + "Downloaded file does not match the release manifest's hash.", + "x" = paste0("Framework: ", slug, "."), + "x" = paste0("Expected sha256: ", entry$sha256, "."), + "x" = paste0("Got sha256: ", actual, "."), + "i" = "The file was deleted rather than cached in an unverified state." + ), + class = "cybedtools_hash_mismatch", + framework_slug = slug + ) + } + } + + tibble::tibble( + framework_slug = slug, + path = dest, + sha256_verified = TRUE + ) + }) + + invisible(dplyr::bind_rows(rows)) +} + +#' Load a graph from the cached release files, fetching first if needed +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' Ensures the requested frameworks are cached (calling [cybed_fetch()] +#' internally, which is a no-op for files already cached with a verified +#' hash) and parses them into one shared rdf object. +#' +#' @inheritParams cybed_fetch +#' @return An rdf object ([rdflib::rdf()]) with every requested framework's +#' triples loaded. +#' @family data loading +#' @export +#' @examples +#' \donttest{ +#' tryCatch( +#' { +#' rdf <- load_graph(frameworks = "nice-v2") +#' framework_metadata(rdf) +#' }, +#' cybedtools_download_failed = function(cnd) message("No network: ", conditionMessage(cnd)) +#' ) +#' } +load_graph <- function(frameworks = NULL, version = NULL) { + fetched <- cybed_fetch(frameworks = frameworks, version = version) + + rdf <- rdflib::rdf() + for (path in fetched$path) { + con <- gzfile(path, "rb") + lines <- tryCatch(readLines(con, warn = FALSE), finally = close(con)) + tmp <- tempfile(fileext = ".nt") + on.exit(unlink(tmp), add = TRUE) + writeLines(lines, tmp) + rdflib::rdf_parse(tmp, rdf = rdf, format = "ntriples") + } + rdf +} diff --git a/R/cybedtools-package.R b/R/cybedtools-package.R index e0b1085..dcb43e9 100644 --- a/R/cybedtools-package.R +++ b/R/cybedtools-package.R @@ -3,9 +3,9 @@ #' @description #' \if{html}{\figure{logo.png}{options: style="float: right; margin-left: 10px; margin-bottom: 10px;" alt="cybedtools logo" width="120"}} #' -#' Eleven cybersecurity workforce and learning frameworks (NICE, DCWF, SFIA, -#' ENISA ECSF, Cyber.org K-12, CSTA K-12 CS, ACM/IEEE CSEC2017, DigComp 2.2, -#' CyQUAL, CCSSF, OTCCF) expressed in a shared `cybed:` semantic schema, +#' Cybersecurity workforce and learning frameworks (NICE, DCWF, SFIA, +#' ENISA ECSF, Cyber.org K-12, CSTA K-12 CS, ACM/IEEE CSEC2017, DigComp 3.0, +#' CyQUAL, CCSSF, OTCCF, and more) expressed in a shared `cybed:` semantic schema, #' with R helpers that query #' across them as if they were one corpus. The package adds a comparison #' layer over existing frameworks rather than proposing a replacement. @@ -24,7 +24,7 @@ #' - The `getting-started` vignette walks through loading a graph and running #' the domain helpers. #' - The `cross-framework-analysis` vignette shows worked findings across the -#' full eleven-framework graph. +#' full corpus graph. #' - The `adding-a-framework` vignette covers extending the package with a #' new framework. #' - Function reference is grouped by family (JSON-LD construction, File I/O, diff --git a/R/data.R b/R/data.R index 527414a..42f01db 100644 --- a/R/data.R +++ b/R/data.R @@ -3,7 +3,7 @@ # Roxygen documentation for shipped package data. Source script that # produces these artifacts lives in data-raw/. -#' Eleven-framework summary tibble +#' Framework summary tibble #' #' One row per framework in the cybedtools corpus. All count columns are #' computed from the staged combined N-Triples graph at package-build time @@ -11,17 +11,42 @@ #' (workforce vs pedagogy), and license are hand-curated because they #' originate outside the JSON-LD graph. #' -#' Three frameworks were added in v0.3.0 on steward terms: CyQUAL +#' Several frameworks were added in v0.3.0 on steward terms: CyQUAL #' (Czech Republic, open data, attribution to CyQUAL and Masaryk -#' University), CCSSF (Canada, Government of Canada copyright, -#' referenced as the Canadian Centre for Cyber Security asked), and OTCCF +#' University), CCSSF (Canada, Government of Canada copyright, used with +#' permission of the Canadian Centre for Cyber Security), and OTCCF #' (Singapore, Cyber Security Agency of Singapore copyright, permission for #' non-commercial academic and research use). The build script fails loudly #' in both directions if the graph and the curated display table disagree #' about which frameworks exist. #' -#' Statement codes are unique only within a framework. NICE and CCSSF both -#' print codes in the T0516 shape, and they denote different statements. +#' The 2026 CSTA PK-12 Computer Science Standards (`csta-2026`) were added +#' after v0.3.0 as a framework of their own alongside the 2017 edition +#' (`csta-2017`), whose row is unchanged. Its organizing units are level x +#' concept groups for the foundational tier and tier x specialty area groups +#' for the specialty tier (see `organizing_unit_count` for the current +#' total). Its Examples are CSTA's published implementation examples. +#' +#' The Saudi Cybersecurity Workforce Framework (`scywf-1.5`, SCyWF - 1.5 : +#' 2026) was added after v0.3.0 by written permission of the National +#' Cybersecurity Authority (NCA), which requires its content to be carried +#' verbatim. Its organizing units span job roles, competency areas, +#' specialty areas and categories (see `organizing_unit_count` and +#' `role_count` for current totals). Only the job roles assert +#' `cybed:Role`. The links from a specialty area to its category and from a +#' role to its specialty area are cybedtools-derived, not NCA content. +#' +#' The Cyber Security Body of Knowledge (`cybok-v1.1.0`, CyBOK v1.1.0, July +#' 2021) was added after v0.3.0 under the Open Government Licence v3.0. Its +#' organizing units are its Knowledge Areas, which assert no `cybed:Role`. +#' Its parent elements are the Topics of each KA's Knowledge Tree, and the +#' Indicative Material the trees draw under each Topic is carried as +#' `cybed:Subpoint`. Those Subpoints are source-drawn, not parser output. +#' +#' Statement codes are unique only within a framework. NICE, CCSSF and +#' SCyWF all print codes in the T0516 shape, and they denote different +#' statements. SCyWF reuses a block of NICE v2.2.0 codes and prints +#' different text under every one of them. #' Never join two frameworks on a bare statement code. Join on the full IRI, #' or carry a framework column alongside the code. #' @@ -30,19 +55,20 @@ #' the framework: parents, plus `cybed:Subpoint` children, plus #' `cybed:Example` children. `element_count_strict` counts parents only, that #' is with-examples less both the Subpoint and the Example populations. -#' Only DCWF, DigComp 2.2 and OTCCF have zero of both children, so strict -#' equals with-examples there. -#' -#' **Corrected 2026-08-14**: `element_count_strict` previously subtracted -#' only `example_count`, so any framework with nonzero Subpoints carried an -#' inflated "strict" value that silently included non-parent content -#' (NICE +4, SFIA +158, ECSF +16, CSTA +20, CSEC2017 +2). That is what -#' produced the NICE 2,115-vs-NIST's-2,111 discrepancy caught in the -#' Concordance manuscript audit. The README headline "density spread" -#' finding uses the with-examples count, which counts what each framework -#' puts in front of a teacher, trainee, or curriculum designer. The strict -#' count is the supplementary figure, reported in the -#' cross-framework-analysis vignette. +#' OTCCF, csta-2026, DigComp 3.0 and SCyWF have zero Subpoints (parser +#' disabled for all four on fidelity grounds. See +#' docs/framework-invariants.yml). CyBOK runs with the parser disabled too, +#' and its Subpoints are the Indicative Material its Knowledge Trees draw. Only OTCCF and SCyWF have zero of both +#' children, so strict equals with-examples there. DigComp 3.0 still has a nonzero `example_count` with the parser +#' off: its Learning Outcomes attach as `cybed:Example` children of each +#' Competence unit, so strict is less than with-examples there. +#' +#' `element_count_strict` excludes both `cybed:Subpoint` and `cybed:Example` +#' content; see NEWS.md for the 2026-08-14 correction to this definition. +#' The README headline "density spread" finding uses the with-examples +#' count, which counts what each framework puts in front of a teacher, +#' trainee, or curriculum designer. The strict count is the supplementary +#' figure, reported in the cross-framework-analysis vignette. #' #' The related count in `docs/framework-invariants.yml`, #' `total_elements_with_subpoints`, is parents plus Subpoints excluding @@ -50,12 +76,14 @@ #' #' @section Units, roles, and density: #' `organizing_unit_count` counts every `cybed:OrganizingUnit`, which for -#' several frameworks mixes more than one kind of unit: NICE contributes 42 -#' work roles plus 11 competency areas, CyQUAL 102 work roles plus 59 -#' competencies, OTCCF 15 job roles plus 30 Technical Skills and -#' Competencies plus 16 Critical Core Skills. A density taken over that -#' mixed denominator is not like-for-like against a framework whose units -#' are all roles, such as DCWF's 74. The `role_count` and +#' several frameworks mixes more than one kind of unit: NICE contributes +#' work roles plus competency areas, CyQUAL work roles plus competencies, +#' OTCCF job roles plus Technical Skills and Competencies plus Critical +#' Core Skills, SCyWF job roles plus competency areas, specialty areas and +#' categories (see `organizing_unit_count` and `role_count` for the +#' current per-framework split). A density taken over that mixed +#' denominator is not like-for-like against a framework whose units are +#' all roles, such as DCWF. The `role_count` and #' `elements_per_role_*` columns added in v0.3.0 give the role-only cut. #' The `elements_per_organizing_unit_*` columns keep the definition they #' have always had, because they are published figures. @@ -69,7 +97,7 @@ #' For structural questions, query `cybed:Role` and `cybed:OrganizingUnit` #' directly. #' -#' @format A tibble with 11 rows and 16 columns. +#' @format A tibble with 14 rows and 16 columns. #' \describe{ #' \item{framework_slug}{Character. Stable slug used as the URI tail #' (e.g., `"nice-v2"`, `"sfia-9"`, `"otccf-v1.1"`). Unique.} @@ -80,15 +108,16 @@ #' of `"workforce"` or `"pedagogy"`. Independent of the structural #' `cybed:Role` vs `cybed:OrganizingUnit` distinction.} #' \item{jurisdiction}{Character. One of `"US"`, `"EU"`, `"CZ"`, `"CA"`, -#' `"SG"`, or `"global"`.} +#' `"SG"`, `"SA"`, `"UK"`, or `"global"`.} #' \item{organizing_unit_count}{Integer. Distinct subjects typed #' `cybed:OrganizingUnit` and bound to the framework via `cybed:partOf`. #' A mixed denominator where a framework publishes more than one kind of #' unit, as described above.} #' \item{role_count}{Integer. Distinct subjects typed `cybed:Role` and #' bound to the framework via `cybed:partOf`. A subset of -#' `organizing_unit_count`. `NA` for the five frameworks that assert no -#' roles at all (SFIA, Cyber.org K-12, CSTA, CSEC2017, DigComp 2.2). +#' `organizing_unit_count`. `NA` for the frameworks that assert no +#' roles at all (SFIA, Cyber.org K-12, CSTA 2017, CSTA 2026, CSEC2017, +#' DigComp 3.0, CyBOK). #' `NA` rather than zero, because "this framework does not use the role #' construct" is a different statement from "this framework has zero #' roles".} @@ -98,21 +127,23 @@ #' both enumeration splitting and pedagogical scaffolding.} #' \item{subpoint_count}{Integer. Distinct `cybed:Subpoint` instances: #' enumeration-list splits parsed out of a parent's text at assembly -#' time, plus, for CCSSF only, sub-bullets the source itself prints -#' beneath a parent bullet. Zero where the parser is disabled (OTCCF) -#' or finds nothing.} -#' \item{example_count}{Integer. Distinct `cybed:Example` instances -#' (Clarification-statement scaffolding). Non-zero for Cyber.org K-12 -#' and CSTA only.} +#' time, plus sub-points the source itself prints or draws beneath a +#' parent: CCSSF's sub-bullets and CyBOK's Indicative Material. Zero where +#' the parser is disabled and the source has none (OTCCF, csta-2026, +#' DigComp 3.0, SCyWF) or where the parser finds nothing.} +#' \item{example_count}{Integer. Distinct `cybed:Example` instances: +#' Clarification-statement scaffolding for Cyber.org K-12 and CSTA 2017, +#' and published implementation examples for CSTA 2026. Zero everywhere +#' else.} #' \item{element_count_with_examples}{Integer. Distinct elements typed #' `cybed:RoleElement` and bound to the framework: parents plus #' Subpoints plus Examples.} #' \item{unit_relation_count}{Integer. Distinct `cybed:UnitRelation` #' nodes bound to the framework: qualified unit-to-unit statements, each #' carrying its published proficiency level. Nonzero for OTCCF only -#' (310: 190 role-to-TSC plus 120 role-to-Critical-Core-Skill), zero -#' everywhere else, since no other framework in the corpus publishes a -#' skills map of this shape.} +#' (role-to-Technical-Skill-Competency plus role-to-Critical-Core-Skill +#' relations), zero everywhere else, since no other framework in the +#' corpus publishes a skills map of this shape.} #' \item{elements_per_organizing_unit_strict}{Numeric. #' `element_count_strict / organizing_unit_count`, rounded to one #' decimal. The supplementary density figure, reported in the @@ -121,7 +152,8 @@ #' `element_count_with_examples / organizing_unit_count`, rounded to one #' decimal. The headline density figure used in the README. The vignette #' shows it alongside the strict column to make visible how Examples -#' inflate Cyber.org K-12 and CSTA's apparent specification density.} +#' inflate the apparent specification density of Cyber.org K-12 and both +#' CSTA editions.} #' \item{elements_per_role_strict}{Numeric. The count of DISTINCT parent #' elements reachable by `cybed:hasElement` from a role-typed organizing #' unit, divided by `role_count`, rounded to one decimal. `NA` where @@ -142,7 +174,7 @@ #' it. Do not treat this column as a statement of terms; it is a label.} #' } #' -#' @source Computed from the eleven-framework combined graph produced by +#' @source Computed from the combined graph produced by #' `scripts/025-export-ntriples.R`. See #' `data-raw/build-framework-summary.R`. #' @examples @@ -189,7 +221,7 @@ #' occurs.} #' } #' -#' @format A tibble with 12 rows and 10 columns. +#' @format A tibble with 15 rows and 10 columns. #' \describe{ #' \item{layer}{Character. `"code"` for the package's own code, or #' `"framework"` for a framework's source content. Exactly one `"code"` diff --git a/R/demo-graph.R b/R/demo-graph.R index e4c5cd3..1a16456 100644 --- a/R/demo-graph.R +++ b/R/demo-graph.R @@ -17,7 +17,7 @@ #' and `cybed:OrganizingUnit`, mirroring NICE / DCWF / ENISA ECSF. #' - **Framework B** (EU, general, general-IT) is non-workforce-shaped: #' its 1 organizing unit is typed `cybed:OrganizingUnit` only, -#' mirroring SFIA / Cyber.org K-12 / CSTA / CSEC2017 / DigComp 2.2. +#' mirroring SFIA / Cyber.org K-12 / CSTA / CSEC2017 / DigComp 3.0. #' #' The graph also includes 5 atomic elements (typed `cybed:RoleElement`), #' one `cybed:Subpoint` (an enumerated child of an element, reachable via diff --git a/R/jsonld-helpers.R b/R/jsonld-helpers.R index f944d0b..6cfb807 100644 --- a/R/jsonld-helpers.R +++ b/R/jsonld-helpers.R @@ -8,7 +8,8 @@ # Two-tier namespace architecture (see the namespace-architecture article): # Tier 1: `cybed:` (framework-agnostic base vocabulary) # Tier 2: per-framework prefixes (nice, dcwf, ecf, sfia, ecsf, cyqual, -# ccssf, otccf, cyberorg, csta, csec, digcomp), each defining +# ccssf, otccf, scywf, cyberorg, csta, csta2026, csec, digcomp, +# cybok), each defining # subclasses of Tier 1 types # --------------------------------------------------------------------------- @@ -36,15 +37,31 @@ cybed_namespaces <- list( # the steward issued. cyqual = "https://w3id.org/cybed/framework/cyqual#", ccssf = "https://w3id.org/cybed/framework/ccssf#", - otccf = "https://w3id.org/cybed/framework/otccf#" + otccf = "https://w3id.org/cybed/framework/otccf#", + # The 2026 CSTA PK-12 standards are a separate framework from the 2017 + # edition, with their own identifier scheme. Their package-coined terms + # (csta2026:StandardGroup and the per-standard literal properties) are + # minted under the cybed namespace for the same reason as above. + csta2026 = "https://w3id.org/cybed/framework/csta2026#", + # Saudi Cybersecurity Workforce Framework, ingested by written permission + # of the National Cybersecurity Authority. Its package-coined terms + # (scywf:JobRole, the group subtypes and the derived hierarchy predicates) + # are minted under the cybed namespace for the same reason as above. + scywf = "https://w3id.org/cybed/framework/scywf#", + # Cyber Security Body of Knowledge (NCSC, Open Government Licence v3.0). + # CyBOK publishes no vocabulary of its own, so its package-coined terms + # (cybok:KnowledgeArea, cybok:Topic, cybok:IndicativeMaterial and + # cybok:category) are minted under the cybed namespace as above. + cybok = "https://w3id.org/cybed/framework/cybok#" ) # Valid framework prefixes (Tier 2). Workforce + pedagogical. valid_framework_prefixes <- c( # Workforce competency frameworks - "nice", "dcwf", "ecf", "sfia", "ecsf", "cyqual", "ccssf", "otccf", - # Pedagogical learning-standards / curriculum frameworks - "cyberorg", "csta", "csec", "digcomp" + "nice", "dcwf", "ecf", "sfia", "ecsf", "cyqual", "ccssf", "otccf", "scywf", + # Pedagogical learning-standards / curriculum frameworks and bodies of + # knowledge + "cyberorg", "csta", "csta2026", "csec", "digcomp", "cybok" ) #' Build a standard JSON-LD `@context` block @@ -58,8 +75,9 @@ valid_framework_prefixes <- c( #' #' @param framework_prefix Character, one of the valid framework prefixes. #' Workforce: `"nice"`, `"dcwf"`, `"ecf"`, `"sfia"`, `"ecsf"`, `"cyqual"`, -#' `"ccssf"`, `"otccf"`. -#' Pedagogical: `"cyberorg"`, `"csta"`, `"csec"`, `"digcomp"`. +#' `"ccssf"`, `"otccf"`, `"scywf"`. +#' Pedagogical: `"cyberorg"`, `"csta"`, `"csta2026"`, `"csec"`, `"digcomp"`, +#' `"cybok"`. #' @return Named list suitable for use as JSON-LD `@context`. #' @family JSON-LD construction #' @export @@ -201,12 +219,12 @@ build_framework_node <- function(framework_id, #' #' Every framework's top-level enumerated unit is an instance of #' `cybed:OrganizingUnit` (subClassOf `skos:Concept`), the cross-framework -#' abstract that lets one SPARQL query reach all eleven frameworks' parent +#' abstract that lets one SPARQL query reach every framework's parent #' units uniformly. Workforce frameworks (NICE, DCWF, ENISA ECSF) where the #' unit is genuinely a work role or work profile additionally assert #' `cybed:Role` (itself `subClassOf cybed:OrganizingUnit`); pass `is_role = #' TRUE` for those. Non-workforce frameworks (SFIA enumerates skills; -#' Cyber.org K-12, CSTA, CSEC2017, DigComp 2.2 enumerate other organizing +#' Cyber.org K-12, CSTA, CSEC2017, DigComp 3.0 enumerate other organizing #' units) assert `cybed:OrganizingUnit` only. #' #' Each unit also carries a per-framework subtype (e.g., `nice:WorkRole`, @@ -353,7 +371,7 @@ build_organizing_unit_node <- function(unit_id, #' Convenience wrapper around [build_organizing_unit_node()] for workforce #' frameworks (NICE, DCWF, ENISA ECSF). Asserts `cybed:Role` in addition to #' `cybed:OrganizingUnit` and the per-framework subtype. For non-workforce -#' frameworks (SFIA, Cyber.org K-12, CSTA, CSEC2017, DigComp 2.2), call +#' frameworks (SFIA, Cyber.org K-12, CSTA, CSEC2017, DigComp 3.0), call #' [build_organizing_unit_node()] directly with `is_role = FALSE`. #' #' @param role_id Character, framework-local identifier (e.g., `"OG-WRL-015"`). diff --git a/R/rdf-graph.R b/R/rdf-graph.R index f77722c..568a4e6 100644 --- a/R/rdf-graph.R +++ b/R/rdf-graph.R @@ -74,8 +74,8 @@ load_combined_rdf_graph <- function(file_path = NULL) { #' isolated SPARQL queries are needed. #' #' @param framework_slug Character, one of `"nice"`, `"sfia"`, `"dcwf"`, -#' `"ecsf"`, `"cyberorg-k12"`, `"csta"`, `"csec2017"`, `"digcomp"`, or -#' `"cyqual"`, `"ccssf"`, or `"otccf"`. +#' `"ecsf"`, `"cyberorg-k12"`, `"csta"`, `"csta-2026"`, `"csec2017"`, +#' `"digcomp"`, `"cyqual"`, `"ccssf"`, `"otccf"`, `"scywf"`, or `"cybok"`. #' @param jsonld_dir Character path to the directory containing per-framework #' JSON-LD files. Defaults to `data/processed/jsonld/`. #' @return An rdf object. @@ -165,9 +165,10 @@ load_combined_ntriples_graph <- function(file_path = NULL) { #' } load_unified_rdf_graph <- function(framework_slugs = c("nice", "sfia", "dcwf", "ecsf", "cyberorg-k12", - "csta", "csec2017", - "digcomp", "cyqual", - "ccssf", "otccf"), + "csta", "csta-2026", + "csec2017", "digcomp", + "cyqual", "ccssf", + "otccf", "scywf", "cybok"), jsonld_dir = NULL) { if (is.null(jsonld_dir)) { jsonld_dir <- here::here("data", "processed", "jsonld") diff --git a/R/similarity-helpers.R b/R/similarity-helpers.R index d257f82..4cea793 100644 --- a/R/similarity-helpers.R +++ b/R/similarity-helpers.R @@ -180,6 +180,131 @@ similarity_strength <- function(x) { ) } +#' Cross-framework unit-text similarity, top-n matches per unit +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' Reproduces, as the one public entry point, the "full-document" Jaccard +#' similarity computed script-locally by the concordance data-prep scripts +#' (`concordance/_data-prep-*.R`, e.g. `_data-prep-nice-ecsf-alignment.R`'s +#' `build_full_doc()`): each organizing unit's comparison text is its own +#' `schema:name`, its own `cybed:elementText` when it carries one directly +#' (true for pedagogy units where the unit and its top-level statement share +#' one IRI, e.g. Cyber.org/CSTA cells), and the concatenated +#' `cybed:elementText` of every element reachable via +#' [unit_element_bindings()] (true for workforce units, whose task/ +#' knowledge/skill statements are separate elements linked by +#' `cybed:hasElement`). This single text-assembly rule is what lets one +#' function serve both structural shapes. Every (from-unit, to-unit) pair is +#' then scored with `jaccard()`, the top `n` matches per from-unit are kept +#' via `top_n_matches()`, and each score's `similarity_strength()` is +#' attached. The tokenizer, the Jaccard set-similarity, the stopword list +#' and the ranking tie-break are internal (`@noRd`) and may change without +#' notice; only this function's signature and output shape are the +#' contract. +#' +#' `from` and `to` select organizing units by `framework_slug` +#' ([organizing_unit_framework_bindings()]`$framework_slug`), matching how +#' the concordance scripts partition units before scoring +#' (`framework_slug == "cyberorg-k12"` vs `"csta-2017"`, for example). A +#' unit with no name, no own text, and no child element text at all +#' contributes no rows on either side (empty document, nothing to compare). +#' +#' @param rdf An rdf object. +#' @param from,to Character scalars, the `framework_slug` values (from +#' [organizing_unit_framework_bindings()]) whose organizing units are +#' compared. May be identical, to find near-duplicate units within one +#' framework. +#' @param n Integer, matches to keep per from-unit (default `5`). +#' @return A tibble with one row per (from unit, match): columns +#' `from_unit`, `to_unit` (both full IRIs), `score` (numeric in `[0, 1]`), +#' `strength` (`"strong"`/`"moderate"`/`"weak"`/`"none"`), and `rank` +#' (integer, `1..k`, dense per `from_unit`). A `from`-side unit with zero +#' candidates on the `to` side contributes no rows. A `from`-side unit +#' whose only candidates all score 0 still appears, ranked, with +#' `strength == "none"`. +#' @family similarity helpers +#' @export +#' @examples +#' rdf <- make_demo_graph() +#' # make_demo_graph()'s two frameworks carry no cybed:elementText or +#' # cybed:hasElement text, so this is a zero-row tibble with the columns +#' # from_unit/to_unit/score/strength/rank. +#' framework_similarity(rdf, from = "demo-fw-a", to = "demo-fw-b") +#' +#' \dontrun{ +#' rdf <- load_combined_ntriples_graph() +#' framework_similarity(rdf, from = "cyberorg-k12", to = "csta-2017", n = 3) +#' } +framework_similarity <- function(rdf, from, to, n = 5) { + stopifnot(is.character(from), length(from) == 1L) + stopifnot(is.character(to), length(to) == 1L) + + units <- organizing_unit_framework_bindings(rdf) + texts <- element_text(rdf) + + child_text <- unit_element_bindings(rdf) |> + dplyr::inner_join(texts, by = "element") |> + dplyr::group_by(.data$role) |> + dplyr::summarise(child_text = paste(.data$text, collapse = " "), .groups = "drop") |> + dplyr::rename(unit = "role") + + own_text <- dplyr::rename(texts, unit = "element", own_text = "text") + + side <- function(slug) { + units |> + dplyr::filter(.data$framework_slug == slug) |> + dplyr::left_join(own_text, by = "unit") |> + dplyr::left_join(child_text, by = "unit") |> + dplyr::mutate( + full_text = trimws(paste( + dplyr::coalesce(.data$unit_name, ""), + dplyr::coalesce(.data$own_text, ""), + dplyr::coalesce(.data$child_text, "") + )) + ) |> + dplyr::filter(nzchar(.data$full_text)) |> + dplyr::transmute( + unit = .data$unit, + tokens = lapply(.data$full_text, tokenize) + ) + } + + from_units <- side(from) + to_units <- side(to) + + empty <- tibble::tibble( + from_unit = character(0), to_unit = character(0), + score = numeric(0), strength = character(0), rank = integer(0) + ) + if (nrow(from_units) == 0 || nrow(to_units) == 0) { + return(empty) + } + + grid <- tidyr::expand_grid( + fi = seq_len(nrow(from_units)), + ti = seq_len(nrow(to_units)) + ) |> + dplyr::mutate( + group = from_units$unit[.data$fi], + candidate = to_units$unit[.data$ti], + score = purrr::map2_dbl( + .data$fi, .data$ti, + ~ jaccard(from_units$tokens[[.x]], to_units$tokens[[.y]]) + ) + ) + + top_n_matches(grid, n = n) |> + dplyr::transmute( + from_unit = .data$group, + to_unit = .data$candidate, + score = round(.data$score, 10), + strength = similarity_strength(.data$score), + rank = .data$rank + ) +} + #' Filter unit bindings by a framework-name pattern, masking-proof #' #' Safe form of the `filter(grepl("^NICE", framework_name))` idiom used in diff --git a/R/sparql-helpers.R b/R/sparql-helpers.R index 4622629..0abd074 100644 --- a/R/sparql-helpers.R +++ b/R/sparql-helpers.R @@ -26,6 +26,24 @@ # 14,583-row parent-child join). Always pass an explicit `by =` when # joining two of these tibbles together. +#' Derive a framework's stable slug from its framework IRI +#' +#' Framework nodes are minted at `cybed:framework/{framework_id}` +#' ([build_framework_node()]), and `framework_id` is exactly the slug +#' documented on [framework_summary]`$framework_slug` (e.g. `"nice-v2"`). +#' This helper takes the text after the final `/` of the expanded IRI, which +#' recovers that slug without a round trip through the framework's +#' `schema:name` literal. +#' +#' @param framework_uri Character vector of framework IRIs (the `framework` +#' column of `framework_metadata()` and the `*_bindings()` helpers). +#' @return Character vector of slugs, same length as `framework_uri`. `NA` +#' input yields `NA` output. +#' @noRd +framework_slug_of <- function(framework_uri) { + sub(".*/", "", framework_uri) +} + #' Default PREFIX declarations used by every helper query #' #' @return Character string with the standard prefixes plus a trailing newline. @@ -119,8 +137,10 @@ sparql_subjects <- function(rdf, predicate, object) { #' #' @param rdf An rdf object. #' @return A tibble with columns `framework`, `name`, `jurisdiction`, -#' `sector`, `specificity`. One row per framework typed as -#' `cybed:Framework`. +#' `sector`, `specificity`, `framework_slug`. One row per framework typed +#' as `cybed:Framework`. `framework_slug` is added (v0.4.0) as a stable +#' join key across frameworks; every existing column is unchanged and +#' row order is unchanged. #' @note The `framework` column holds the framework's full URI. Avoid #' naming a local variable or function parameter `framework` in code #' that filters or mutates this tibble -- dplyr's data masking silently @@ -158,7 +178,8 @@ framework_metadata <- function(rdf) { dplyr::left_join( dplyr::rename(specs, specificity = "o"), by = c("framework" = "s") - ) + ) |> + dplyr::mutate(framework_slug = framework_slug_of(.data$framework)) } #' Domain helper: role-to-framework bindings with framework name attached @@ -175,14 +196,14 @@ framework_metadata <- function(rdf) { #' Knowledge Areas, and DigComp competence areas are not roles and are #' not returned by this helper. Use [organizing_unit_framework_bindings()] #' for the cross-framework "top-level enumerated unit" cut that includes -#' all eleven frameworks. +#' every framework in the corpus. #' #' Roles without a `cybed:partOf` triple, or whose partOf target is not #' typed `cybed:Framework`, are excluded. #' #' @param rdf An rdf object. #' @return A tibble with columns `role`, `role_name`, `framework`, -#' `framework_name`. +#' `framework_name`, `framework_slug` (added v0.4.0, a stable join key). #' @note The `framework` column holds the framework's full URI. Avoid #' naming a local variable or function parameter `framework` in code #' that filters or mutates this tibble -- dplyr's data masking silently @@ -221,7 +242,8 @@ role_framework_bindings <- function(rdf) { dplyr::left_join( dplyr::rename(fw_names, framework_name = "o"), by = c("framework" = "s") - ) + ) |> + dplyr::mutate(framework_slug = framework_slug_of(.data$framework)) } #' Domain helper: organizing-unit-to-framework bindings with framework name attached @@ -229,8 +251,8 @@ role_framework_bindings <- function(rdf) { #' @description #' `r lifecycle::badge("stable")` #' -#' One row per (organizing unit, framework) pair across all eleven -#' frameworks. Queries on the cross-framework abstract type +#' One row per (organizing unit, framework) pair across every framework in +#' the corpus. Queries on the cross-framework abstract type #' `cybed:OrganizingUnit`, which every framework's top-level enumerated #' unit asserts (work roles, work profiles, skills, grade-band x sub-concept cells, #' level x concept cells, Knowledge Areas, competence areas). Use this @@ -243,7 +265,7 @@ role_framework_bindings <- function(rdf) { #' #' @param rdf An rdf object. #' @return A tibble with columns `unit`, `unit_name`, `framework`, -#' `framework_name`. +#' `framework_name`, `framework_slug` (added v0.4.0, a stable join key). #' @note The `framework` column holds the framework's full URI. Avoid #' naming a local variable or function parameter `framework` in code #' that filters or mutates this tibble -- dplyr's data masking silently @@ -285,7 +307,8 @@ organizing_unit_framework_bindings <- function(rdf) { dplyr::left_join( dplyr::rename(fw_names, framework_name = "o"), by = c("framework" = "s") - ) + ) |> + dplyr::mutate(framework_slug = framework_slug_of(.data$framework)) } #' Domain helper: element-to-framework bindings with framework name attached @@ -304,7 +327,8 @@ organizing_unit_framework_bindings <- function(rdf) { #' counting column in `framework_summary`). #' #' @param rdf An rdf object. -#' @return A tibble with columns `element`, `framework`, `framework_name`. +#' @return A tibble with columns `element`, `framework`, `framework_name`, +#' `framework_slug` (added v0.4.0, a stable join key). #' @note The `framework` column holds the framework's full URI. Avoid #' naming a local variable or function parameter `framework` in code #' that filters or mutates this tibble -- dplyr's data masking silently @@ -337,7 +361,8 @@ element_framework_bindings <- function(rdf) { dplyr::left_join( dplyr::rename(fw_names, framework_name = "o"), by = c("framework" = "s") - ) + ) |> + dplyr::mutate(framework_slug = framework_slug_of(.data$framework)) } #' Domain helper: example-to-framework bindings with framework name attached @@ -359,7 +384,8 @@ element_framework_bindings <- function(rdf) { #' total element count. #' #' @param rdf An rdf object. -#' @return A tibble with columns `example`, `framework`, `framework_name`. +#' @return A tibble with columns `example`, `framework`, `framework_name`, +#' `framework_slug` (added v0.4.0, a stable join key). #' @note The `framework` column holds the framework's full URI. Avoid #' naming a local variable or function parameter `framework` in code #' that filters or mutates this tibble -- dplyr's data masking silently @@ -392,7 +418,8 @@ example_framework_bindings <- function(rdf) { dplyr::left_join( dplyr::rename(fw_names, framework_name = "o"), by = c("framework" = "s") - ) + ) |> + dplyr::mutate(framework_slug = framework_slug_of(.data$framework)) } #' Domain helper: subpoint-to-framework bindings with framework name attached @@ -404,7 +431,7 @@ example_framework_bindings <- function(rdf) { #' `cybed:Subpoint` (the generic enumeration-list-splitting subtype -- #' "such as X, Y, and Z" / "including A and B" -- parsed out of a single #' native unit's text at JSON-LD assembly time, applied uniformly across -#' all eleven frameworks) and its `partOf` target is typed +#' the corpus) and its `partOf` target is typed #' `cybed:Framework`. Subpoints without a valid framework partOf are #' excluded. #' @@ -419,7 +446,8 @@ example_framework_bindings <- function(rdf) { #' column, which does exactly this. #' #' @param rdf An rdf object. -#' @return A tibble with columns `subpoint`, `framework`, `framework_name`. +#' @return A tibble with columns `subpoint`, `framework`, `framework_name`, +#' `framework_slug` (added v0.4.0, a stable join key). #' @note The `framework` column holds the framework's full URI. Avoid #' naming a local variable or function parameter `framework` in code #' that filters or mutates this tibble -- dplyr's data masking silently @@ -452,7 +480,8 @@ subpoint_framework_bindings <- function(rdf) { dplyr::left_join( dplyr::rename(fw_names, framework_name = "o"), by = c("framework" = "s") - ) + ) |> + dplyr::mutate(framework_slug = framework_slug_of(.data$framework)) } #' Domain helper: statement text keyed by element @@ -526,11 +555,12 @@ element_text <- function(rdf) { #' One row per (parent, element) pair derived from `cybed:hasElement` #' triples. Despite the "role" naming, the `role` column is NOT restricted #' to `cybed:Role` subjects -- `cybed:hasElement` is the universal parent- -#' child link used across all eleven frameworks, so this returns element -#' bindings for every `cybed:OrganizingUnit` (SFIA skills, Cyber.org K-12 -#' and CSTA standards, etc.), not just NICE/DCWF/ECSF work roles. Confirmed -#' 2026-08-14 stress test: of 428 distinct values in the `role` column, -#' only 127 (30%) are actual `cybed:Role` subjects. Low practical risk when +#' child link used across every framework in the corpus, so this returns +#' element bindings for every `cybed:OrganizingUnit` (SFIA skills, +#' Cyber.org K-12 and CSTA standards, etc.), not just NICE/DCWF/ECSF work +#' roles. Confirmed by a 2026-08-14 stress test: most distinct values in +#' the `role` column are not actual `cybed:Role` subjects. Low practical +#' risk when #' immediately joined against [role_framework_bindings()] or #' [organizing_unit_framework_bindings()] (the mismatches drop out), but a #' standalone aggregate over this tibble's `role` column (e.g. "average @@ -539,7 +569,44 @@ element_text <- function(rdf) { #' matters for your analysis. #' #' @param rdf An rdf object. -#' @return A tibble with columns `role`, `element`. +#' @return A tibble with columns `role`, `element`, `framework_slug`. +#' `framework_slug` (added v0.4.0) is the slug of the `role` (organizing +#' unit) subject's own framework, taken from +#' [organizing_unit_framework_bindings()]; a `role` with no valid +#' `cybed:partOf` to a `cybed:Framework` carries `NA` here rather than +#' dropping the row, so this helper's row count is unchanged from before +#' v0.4.0. +#' @family SPARQL helpers +#' @export +#' @examples +#' \dontrun{ +#' rdf <- load_combined_ntriples_graph() +#' unit_element_bindings(rdf) +#' } +unit_element_bindings <- function(rdf) { + has_element <- sparql_pairs(rdf, "cybed:hasElement") + bindings <- dplyr::transmute(has_element, role = .data$s, element = .data$o) + + units <- organizing_unit_framework_bindings(rdf) |> + dplyr::transmute(role = .data$unit, framework_slug = .data$framework_slug) + + bindings |> + dplyr::left_join(units, by = "role") +} + +#' Deprecated alias for [unit_element_bindings()] +#' +#' @description +#' `r lifecycle::badge("deprecated")` +#' +#' `role_element_bindings()` is deprecated as of cybedtools 0.4.0 in favor of +#' [unit_element_bindings()], which returns the identical result (all +#' organizing units, not just `cybed:Role` subjects) under a name that +#' doesn't overstate the role restriction. This alias will be removed in a +#' future minor version; update call sites to `unit_element_bindings()`. +#' +#' @inheritParams unit_element_bindings +#' @return See [unit_element_bindings()]. #' @family SPARQL helpers #' @export #' @examples @@ -548,6 +615,65 @@ element_text <- function(rdf) { #' role_element_bindings(rdf) #' } role_element_bindings <- function(rdf) { - has_element <- sparql_pairs(rdf, "cybed:hasElement") - dplyr::transmute(has_element, role = .data$s, element = .data$o) + warning( + paste( + "`role_element_bindings()` was deprecated in cybedtools 0.4.0.", + "Use `unit_element_bindings()` instead." + ), + call. = FALSE + ) + unit_element_bindings(rdf) +} + +#' Domain helper: unit-to-unit relation bindings +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' One row per `cybed:UnitRelation` node ([build_unit_relation_node()]), +#' giving the related pair of organizing units plus the relation's own +#' framework attribution. `relation` is `NA` for a plain (unlabeled) +#' relation; `framework_slug` is `NA` when the relation node carries no +#' `cybed:partOf`, or one whose target is not typed `cybed:Framework`. +#' +#' @param rdf An rdf object. +#' @return A tibble with columns `from_unit`, `relation`, `to_unit`, +#' `framework_slug`. +#' @family SPARQL helpers +#' @export +#' @examples +#' \dontrun{ +#' rdf <- load_combined_ntriples_graph() +#' unit_relation_bindings(rdf) +#' } +unit_relation_bindings <- function(rdf) { + relations <- sparql_subjects(rdf, "a", "cybed:UnitRelation") |> + dplyr::transmute(relation_id = .data$s) + from_unit <- sparql_pairs(rdf, "cybed:fromUnit") + to_unit <- sparql_pairs(rdf, "cybed:toUnit") + labels <- sparql_pairs(rdf, "cybed:relationLabel") + partof <- sparql_pairs(rdf, "cybed:partOf") + fws <- sparql_subjects(rdf, "a", "cybed:Framework") + + fw_of_relation <- partof |> + dplyr::rename(relation_id = "s", framework = "o") |> + dplyr::semi_join(dplyr::rename(fws, framework = "s"), by = "framework") |> + dplyr::mutate(framework_slug = framework_slug_of(.data$framework)) |> + dplyr::select("relation_id", "framework_slug") + + relations |> + dplyr::left_join( + dplyr::rename(from_unit, relation_id = "s", from_unit = "o"), + by = "relation_id" + ) |> + dplyr::left_join( + dplyr::rename(to_unit, relation_id = "s", to_unit = "o"), + by = "relation_id" + ) |> + dplyr::left_join( + dplyr::rename(labels, relation_id = "s", relation = "o"), + by = "relation_id" + ) |> + dplyr::left_join(fw_of_relation, by = "relation_id") |> + dplyr::select("from_unit", "relation", "to_unit", "framework_slug") } diff --git a/README.md b/README.md index 883f964..58c6379 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ from scratch every time, in a spreadsheet. cybedtools makes the comparison one simple query. -The package ingests seven workforce competency frameworks +The package ingests eight workforce competency frameworks ([NICE](https://www.nist.gov/itl/applied-cybersecurity/nice/nice-framework-resource-center), [DCWF](https://www.cyberworkforce.mil/Department-Cyber-Workforce-Framework/), [SFIA](https://sfia-online.org/en), [ENISA @@ -42,22 +42,27 @@ ECSF](https://www.enisa.europa.eu/topics/skills-and-competences/skills-developme the Czech [CyQUAL](https://platform.cyqual.cz/en), the [Canadian Cyber Security Skills Framework](https://www.cyber.gc.ca/en/education-community/cyber-skills-development/canadian-cyber-security-skills-framework), -and Singapore’s -[OTCCF](https://www.csa.gov.sg/resources/publications/operational-technology-cybersecurity-competency-framework--otccf-/)) -and four pedagogical or learning-standards frameworks ([Cyber.org +Singapore’s +[OTCCF](https://www.csa.gov.sg/resources/publications/operational-technology-cybersecurity-competency-framework--otccf-/), +and Saudi Arabia’s [SCyWF](https://nca.gov.sa/en/pages/scywf.html)) and +six pedagogical, learning-standards or body-of-knowledge frameworks +([Cyber.org K-12](https://cyber.org/k-12-cybersecurity-learning-standards), [CSTA -K-12 CS](https://csteachers.org/2017standards/interactive/), [ACM/IEEE +K-12 CS (2017)](https://csteachers.org/2017standards/interactive/), +[CSTA PK-12 CS (2026)](https://csteachers.org/pk12standards/), [ACM/IEEE CSEC2017](https://cybered.acm.org/), [JRC DigComp -2.2](https://joint-research-centre.ec.europa.eu/scientific-activities/key-competences-lifelong-learning/digital-competence-framework-digcomp_en)). -All eleven are expressed in a shared `cybed:` semantic schema. A small -set of R helpers queries across them as if they were one corpus. +3.0](https://joint-research-centre.ec.europa.eu/scientific-activities/key-competences-lifelong-learning/digital-competence-framework-digcomp_en), +and the UK’s [CyBOK](https://www.cybok.org/)). All fourteen are +expressed in a shared `cybed:` semantic schema. A small set of R helpers +queries across them as if they were one corpus. It does not propose a replacement framework or attempt to re-author framework content. Existing frameworks retain their structure and vocabulary. The package adds a comparison layer. -A native Python package is in development. The name is reserved on -[PyPI](https://pypi.org/project/cybedtools/). +A native Python package mirroring the R API is available: +`pip install cybedtools` (see +[PyPI](https://pypi.org/project/cybedtools/)). The documentation is indexed in [Context7](https://context7.com/ryanstraight/cybedtools) and published @@ -359,7 +364,7 @@ All rights reserved; educational use -DigComp 2.2 +DigComp 3.0 @@ -374,12 +379,12 @@ EU -5 +26 -21 +884 @@ -491,19 +496,122 @@ CSA copyright; non-commercial academic + + + + +CSTA PK-12 CS (2026) + + + + +pedagogy + + + + +US + + + + +53 + + + + +983 + + + + +CC BY-NC-SA 4.0 + + + + + + + + +SCyWF 1.5 + + + + +workforce + + + + +SA + + + + +81 + + + + +1,419 + + + + +NCA written permission + + + + + + + + +CyBOK v1.1.0 + + + + +pedagogy + + + + +UK + + + + +21 + + + + +596 + + + + +OGL v3.0 + + + + The “Top-level units” column reports each framework’s own top-level enumerated unit, and those units are not the same kind of thing. NICE, -DCWF, ECSF, CyQUAL, the Canadian framework, and OTCCF declare roles. -SFIA declares skills, CSEC2017 declares Knowledge Areas, DigComp 2.2 -declares competence areas, and Cyber.org K-12 and CSTA use unnamed -groupings that cybedtools labels `StandardGroup`. All of them subclass -`cybed:OrganizingUnit`, so cross-framework queries reach them uniformly. -How the “Elements” column counts, and how `framework_summary` reports -strict, augmented, and per-role totals, is in +DCWF, ECSF, CyQUAL, the Canadian framework, OTCCF, and SCyWF declare +roles. SFIA declares skills, CSEC2017 and CyBOK declare Knowledge Areas, +DigComp 3.0 declares competence areas and competences, and Cyber.org +K-12 and both CSTA editions use unnamed groupings that cybedtools labels +`StandardGroup`. All of them subclass `cybed:OrganizingUnit`, so +cross-framework queries reach them uniformly. How the “Elements” column +counts, and how `framework_summary` reports strict, augmented, and +per-role totals, is in [`docs/framework-data-sources.md`](docs/framework-data-sources.md). ## What you could not do before @@ -545,14 +653,14 @@ statements |> count(framework_name, framework_name_2, sort = TRUE) |> slice_head(n = 6) #> # A tibble: 6 × 3 -#> framework_name framework_name_2 n -#> -#> 1 CyQUAL 1.2.0 DCWF v5.1 1400 -#> 2 CyQUAL 1.2.0 ECSF v1 132 -#> 3 CyQUAL 1.2.0 NICE v2.2.0 (NI… 100 -#> 4 DCWF v5.1 NICE v2.2.0 (NI… 100 -#> 5 Canadian Cyber Security Skills Framework 2022 (ITSM.00… CyQUAL 1.2.0 2 -#> 6 Canadian Cyber Security Skills Framework 2022 (ITSM.00… DCWF v5.1 2 +#> framework_name framework_name_2 n +#> +#> 1 CyQUAL 1.2.0 DCWF v5.1 1400 +#> 2 NICE v2.2.0 (NIST SP 800-181 Rev 1 components) SCyWF – 1.5: 2026 766 +#> 3 CyQUAL 1.2.0 ECSF v1 132 +#> 4 CyQUAL 1.2.0 NICE v2.2.0 (NIST SP 800… 100 +#> 5 DCWF v5.1 NICE v2.2.0 (NIST SP 800… 100 +#> 6 DCWF v5.1 SCyWF – 1.5: 2026 78 ``` @@ -566,7 +674,9 @@ NIST’s 2017 release, nearly all of what the two share is 2017 NICE text, and under 5 percent of that 2017 text survives in NICE v2.2.0. The 2017 edition is better preserved in Prague and at the Pentagon than at NIST. Canada’s framework describes itself as an adaptation of NICE and shares -under 1 percent of its statements with anything. +under 1 percent of its statements with anything. The second-largest +block belongs to Saudi Arabia’s SCyWF, which shares 766 statements with +NICE v2.2.0. ### Which frameworks have nothing to say about a topic? @@ -598,27 +708,32 @@ imap(topics, \(pattern, topic) { relocate(statements, .after = framework_name) |> arrange(desc(statements)) |> print(n = Inf) -#> # A tibble: 11 × 6 +#> # A tibble: 14 × 6 #> framework_name statements ai ethics cryptography supply_chain #> #> 1 DCWF v5.1 4052 24 12 52 18 #> 2 CyQUAL 1.2.0 3340 1 9 29 18 #> 3 NICE v2.2.0 (NIST SP 800-1… 2225 2 3 26 22 #> 4 Operational Technology Cyb… 1612 1 6 31 12 -#> 5 Canadian Cyber Security Sk… 1345 0 5 36 29 -#> 6 SFIA 9 821 6 15 0 11 -#> 7 Cyber.org K-12 Learning St… 492 0 8 10 0 -#> 8 ECSF v1 390 0 4 0 1 -#> 9 CSTA K-12 Computer Science… 258 2 8 3 0 -#> 10 CSEC2017 Curricular Guidel… 40 0 3 1 1 -#> 11 DigComp 2.2 21 0 0 0 0 +#> 5 SCyWF – 1.5: 2026 1419 8 1 18 12 +#> 6 Canadian Cyber Security Sk… 1345 0 5 36 29 +#> 7 2026 CSTA PK-12 Computer S… 983 34 46 9 1 +#> 8 DigComp 3.0 884 6 79 0 0 +#> 9 SFIA 9 821 6 15 0 11 +#> 10 CyBOK v1.1.0 596 1 1 29 0 +#> 11 Cyber.org K-12 Learning St… 492 0 8 10 0 +#> 12 ECSF v1 390 0 4 0 1 +#> 13 CSTA K-12 Computer Science… 258 2 8 3 0 +#> 14 CSEC2017 Curricular Guidel… 40 0 3 1 1 ``` -Five of the eleven frameworks contain no statement that mentions +Four of the fourteen frameworks contain no statement that mentions artificial intelligence or machine learning. DCWF has 24 such -statements, more than the other ten frameworks combined. NICE has three -statements that mention ethics, out of 2,225. SFIA, ECSF and DigComp -have none that mention cryptography. +statements, more than the other seven workforce frameworks combined +(18). The 2026 CSTA standards, which add a machine learning subconcept +at every foundational level, have 34. NICE has three statements that +mention ethics, out of 2,225. SFIA, ECSF and DigComp have none that +mention cryptography. This measures the words a framework uses. It does not measure what a framework covers. OTCCF is a framework about operational technology and @@ -640,26 +755,27 @@ role_element_bindings(rdf) |> .by = framework_name ) |> arrange(desc(mean_roles)) -#> # A tibble: 6 × 5 +#> # A tibble: 7 × 5 #> framework_name statements mean_roles max_roles used_once_pct #> #> 1 CyQUAL 1.2.0 3151 5 50 33 -#> 2 NICE v2.2.0 (NIST SP 800-181 Re… 1877 2.9 41 51 -#> 3 DCWF v5.1 4052 2.4 74 55 -#> 4 ECSF v1 390 1 1 100 -#> 5 Canadian Cyber Security Skills … 1345 1 1 100 -#> 6 Operational Technology Cybersec… 270 1 1 100 +#> 2 SCyWF – 1.5: 2026 1418 3.4 40 36 +#> 3 NICE v2.2.0 (NIST SP 800-181 Re… 1877 2.9 41 51 +#> 4 DCWF v5.1 4052 2.4 74 55 +#> 5 ECSF v1 390 1 1 100 +#> 6 Canadian Cyber Security Skills … 1345 1 1 100 +#> 7 Operational Technology Cybersec… 270 1 1 100 ``` -Three of the role-based frameworks attach one statement to many roles. +Four of the role-based frameworks attach one statement to many roles. Three never do. In DCWF, 8 statements are attached to all 74 work roles, which makes them a common core the framework does not name. In CyQUAL -the most widely shared statement reaches 50 of 102 roles. In ECSF, CCSSF -and OTCCF every statement belongs to exactly one role, so “how many -roles need this skill” cannot be answered from those frameworks’ own -data, and an element count does not show the difference. OTCCF’s row -counts the key tasks attached to its job roles. Its skill statements -attach to skills. +the most widely shared statement reaches 50 of 102 roles. In SCyWF, 4 +statements are attached to all 40 job roles. In ECSF, CCSSF and OTCCF +every statement belongs to exactly one role, so “how many roles need +this skill” cannot be answered from those frameworks’ own data, and an +element count does not show the difference. OTCCF’s row counts the key +tasks attached to its job roles. Its skill statements attach to skills. ## Finding things with `cybedtools` @@ -682,22 +798,23 @@ unlike denominators. It is not a quality claim. `framework_summary` also carries a `_strict` variant for analyses that prefer each framework’s own count. -### US frameworks are about 48 percent of the corpus, and NICE’s reach is wider than that +### US frameworks are about 43 percent of the corpus, and NICE’s reach is wider than that -The corpus spans 6 jurisdictions and 14,596 elements, counted with +The corpus spans 8 jurisdictions and 18,457 elements, counted with parsed examples included. US frameworks (NICE v2.2.0, DCWF v5.1, -Cyber.org K-12 v1.0, CSTA K-12 CS (Rev 2017)) contribute 7,027 of them. -The two EU-level frameworks (ECSF v1 and DigComp 2.2) contribute 411, -which reflects design intent: ECSF profiles point to e-CF 4.0 -competences instead of restating them, and DigComp 2.2’s annex examples -are not yet extracted. Counting by jurisdiction understates how far one -framework travels. Canada’s framework describes itself as an adaptation -of NICE for the Canadian labour market and cites NICE work roles -throughout. CyQUAL says its structure and elements were adopted from -NICE, and its task list carries the 2017 NICE task codes. Both describe -the borrowing openly. The graph makes it countable. Element counts used -as a coverage metric are evidence of lineage and design, and they do not -measure how much work a framework represents. +Cyber.org K-12 v1.0, CSTA K-12 CS (Rev 2017), CSTA PK-12 CS (2026)) +contribute 8,010 of them. The two EU-level frameworks (ECSF v1 and +DigComp 3.0) contribute 1,274, which reflects design intent: ECSF +profiles point to e-CF 4.0 competences instead of restating them, and +DigComp is an intentionally high-level citizen self-assessment +instrument. Counting by jurisdiction understates how far one framework +travels. Canada’s framework describes itself as an adaptation of NICE +for the Canadian labour market and cites NICE work roles throughout. +CyQUAL says its structure and elements were adopted from NICE, and its +task list carries the 2017 NICE task codes. Both describe the borrowing +openly. The graph makes it countable. Element counts used as a coverage +metric are evidence of lineage and design, and they do not measure how +much work a framework represents. ### Five NICE work roles carry a disproportionate share of the specification @@ -753,18 +870,18 @@ organizing_unit_framework_bindings(rdf) |> ) |> mutate(elements_per_unit = round(element_count / top_level_unit_count, 1)) |> arrange(desc(elements_per_unit)) -#> # A tibble: 11 × 4 +#> # A tibble: 14 × 4 #> framework_name top_level_unit_count element_count elements_per_unit #> #> 1 DCWF v5.1 74 4052 54.8 #> 2 NICE v2.2.0 (NIST SP 800… 53 2225 42 -#> 3 ECSF v1 12 390 32.5 -#> 4 Operational Technology C… 61 1612 26.4 -#> 5 Canadian Cyber Security … 59 1345 22.8 -#> 6 CyQUAL 1.2.0 161 3340 20.7 -#> 7 CSTA K-12 Computer Scien… 25 258 10.3 -#> 8 SFIA 9 147 821 5.6 -#> # ℹ 3 more rows +#> 3 DigComp 3.0 26 884 34 +#> 4 ECSF v1 12 390 32.5 +#> 5 CyBOK v1.1.0 21 596 28.4 +#> 6 Operational Technology C… 61 1612 26.4 +#> 7 Canadian Cyber Security … 59 1345 22.8 +#> 8 CyQUAL 1.2.0 161 3340 20.7 +#> # ℹ 6 more rows ``` Jurisdiction pivots, top-load NICE roles, pairwise framework @@ -815,9 +932,8 @@ each ingestion script writes a per-framework `provenance.yml`. See [`LICENSING.md`](LICENSING.md) for layered guidance on academic vs. commercial use. -Three frameworks here come on terms narrower than MIT. Two of them are -represented by written permission of their stewards, and the Canadian -framework is referenced as its steward asked. Those terms were given to +Three frameworks here come on terms narrower than MIT, all represented +by written permission of their stewards. Those terms were given to cybedtools and do not pass to you. - Derived from the Operational Technology Cybersecurity Competency @@ -828,7 +944,7 @@ cybedtools and do not pass to you. academic and research use. - Canadian Centre for Cyber Security, The Canadian Cyber Security Skills Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada. - Referenced as the Canadian Centre for Cyber Security asked. + Used with permission of the Canadian Centre for Cyber Security. - CyQUAL, the Czech national cybersecurity qualifications framework, developed at Masaryk University. Open data, version 1.2.0, . diff --git a/README.qmd b/README.qmd index 70a6359..10c572a 100644 --- a/README.qmd +++ b/README.qmd @@ -67,6 +67,16 @@ us_fmt <- format(us_elements, big.mark = ",") eu_fmt <- format(eu_elements, big.mark = ",") all_fmt <- format(all_elements, big.mark = ",") n_juris <- nrow(juris_totals) + +# Framework counts for the opening paragraph, from the shipped summary. +spell_n <- function(n) { + words <- c("one", "two", "three", "four", "five", "six", "seven", "eight", + "nine", "ten", "eleven", "twelve", "thirteen", "fourteen") + if (n >= 1 && n <= length(words)) words[n] else format(n, big.mark = ",") +} +n_workforce_word <- spell_n(sum(fs$framework_type == "workforce")) +n_pedagogy_word <- spell_n(sum(fs$framework_type == "pedagogy")) +n_all_word <- spell_n(nrow(fs)) us_fws <- paste(fs$framework_name[fs$jurisdiction == "US"], collapse = ", ") eu_fws <- paste(fs$framework_name[fs$jurisdiction == "EU"], collapse = " and ") @@ -124,11 +134,11 @@ A corpus of cybersecurity workforce and learning frameworks. Each carries a diff cybedtools makes the comparison one simple query. -The package ingests seven workforce competency frameworks ([NICE](https://www.nist.gov/itl/applied-cybersecurity/nice/nice-framework-resource-center), [DCWF](https://www.cyberworkforce.mil/Department-Cyber-Workforce-Framework/), [SFIA](https://sfia-online.org/en), [ENISA ECSF](https://www.enisa.europa.eu/topics/skills-and-competences/skills-development/european-cybersecurity-skills-framework-ecsf), the Czech [CyQUAL](https://platform.cyqual.cz/en), the [Canadian Cyber Security Skills Framework](https://www.cyber.gc.ca/en/education-community/cyber-skills-development/canadian-cyber-security-skills-framework), and Singapore's [OTCCF](https://www.csa.gov.sg/resources/publications/operational-technology-cybersecurity-competency-framework--otccf-/)) and four pedagogical or learning-standards frameworks ([Cyber.org K-12](https://cyber.org/k-12-cybersecurity-learning-standards), [CSTA K-12 CS](https://csteachers.org/2017standards/interactive/), [ACM/IEEE CSEC2017](https://cybered.acm.org/), [JRC DigComp 2.2](https://joint-research-centre.ec.europa.eu/scientific-activities/key-competences-lifelong-learning/digital-competence-framework-digcomp_en)). All eleven are expressed in a shared `cybed:` semantic schema. A small set of R helpers queries across them as if they were one corpus. +The package ingests `r n_workforce_word` workforce competency frameworks ([NICE](https://www.nist.gov/itl/applied-cybersecurity/nice/nice-framework-resource-center), [DCWF](https://www.cyberworkforce.mil/Department-Cyber-Workforce-Framework/), [SFIA](https://sfia-online.org/en), [ENISA ECSF](https://www.enisa.europa.eu/topics/skills-and-competences/skills-development/european-cybersecurity-skills-framework-ecsf), the Czech [CyQUAL](https://platform.cyqual.cz/en), the [Canadian Cyber Security Skills Framework](https://www.cyber.gc.ca/en/education-community/cyber-skills-development/canadian-cyber-security-skills-framework), Singapore's [OTCCF](https://www.csa.gov.sg/resources/publications/operational-technology-cybersecurity-competency-framework--otccf-/), and Saudi Arabia's [SCyWF](https://nca.gov.sa/en/pages/scywf.html)) and `r n_pedagogy_word` pedagogical, learning-standards or body-of-knowledge frameworks ([Cyber.org K-12](https://cyber.org/k-12-cybersecurity-learning-standards), [CSTA K-12 CS (2017)](https://csteachers.org/2017standards/interactive/), [CSTA PK-12 CS (2026)](https://csteachers.org/pk12standards/), [ACM/IEEE CSEC2017](https://cybered.acm.org/), [JRC DigComp 3.0](https://joint-research-centre.ec.europa.eu/scientific-activities/key-competences-lifelong-learning/digital-competence-framework-digcomp_en), and the UK's [CyBOK](https://www.cybok.org/)). All `r n_all_word` are expressed in a shared `cybed:` semantic schema. A small set of R helpers queries across them as if they were one corpus. It does not propose a replacement framework or attempt to re-author framework content. Existing frameworks retain their structure and vocabulary. The package adds a comparison layer. -A native Python package is in development. The name is reserved on [PyPI](https://pypi.org/project/cybedtools/). +A native Python package mirroring the R API is available: `pip install cybedtools` (see [PyPI](https://pypi.org/project/cybedtools/)). The documentation is indexed in [Context7](https://context7.com/ryanstraight/cybedtools) and published as [llms.txt](https://ryanstraight.github.io/cybedtools/llms.txt) for AI assistants. @@ -142,7 +152,7 @@ Who it's for: ```{r framework-table} #| echo: false #| results: asis -# Eleven-framework summary, rendered as raw HTML so pkgdown extra.js can +# Per-framework summary, rendered as raw HTML so pkgdown extra.js can # bind click-to-sort to the .sortable class. Source data is # cybedtools::framework_summary (computed by # data-raw/build-framework-summary.R against the staged combined graph). @@ -173,28 +183,40 @@ cat(paste(rows, collapse = "\n"), "\n", sep = "") cat("\n\n") ``` -The "Top-level units" column reports each framework's own top-level enumerated unit, and those units are not the same kind of thing. NICE, DCWF, ECSF, CyQUAL, the Canadian framework, and OTCCF declare roles. SFIA declares skills, CSEC2017 declares Knowledge Areas, DigComp 2.2 declares competence areas, and Cyber.org K-12 and CSTA use unnamed groupings that cybedtools labels `StandardGroup`. All of them subclass `cybed:OrganizingUnit`, so cross-framework queries reach them uniformly. How the "Elements" column counts, and how `framework_summary` reports strict, augmented, and per-role totals, is in [`docs/framework-data-sources.md`](docs/framework-data-sources.md). +The "Top-level units" column reports each framework's own top-level enumerated unit, and those units are not the same kind of thing. NICE, DCWF, ECSF, CyQUAL, the Canadian framework, OTCCF, and SCyWF declare roles. SFIA declares skills, CSEC2017 and CyBOK declare Knowledge Areas, DigComp 3.0 declares competence areas and competences, and Cyber.org K-12 and both CSTA editions use unnamed groupings that cybedtools labels `StandardGroup`. All of them subclass `cybed:OrganizingUnit`, so cross-framework queries reach them uniformly. How the "Elements" column counts, and how `framework_summary` reports strict, augmented, and per-role totals, is in [`docs/framework-data-sources.md`](docs/framework-data-sources.md). ```{r value-setup} #| include: false # Every figure cited by the "What you could not do before" section. Baked -# fallbacks equal the values computed on 2026-09-19 against the staged -# eleven-framework graph, so the prose stays true when DEMO_LIVE is false. +# fallbacks equal the values computed on 2026-09-21 against the staged +# fourteen-framework graph, so the prose stays true when DEMO_LIVE is false. cq_dc_fmt <- "1,400" +# Not computed from package data: the August 2017 NICE Framework is not +# in the corpus graph, so this cannot be derived at render time. Measured +# 2026-09-19 against the NIST SP 800-181 supplemental task and KSA +# workbook, normalised the same way as the value-shared-text chunk and on +# the same parents-only basis. Held here as the single source so the +# prose below never hand-types it. +nice2017_survival_pct <- 5 shared_nice_n <- 100 ccssf_pct_ceiling <- 1 -ai_zero_n <- "Five" -n_frameworks <- "eleven" +ai_zero_n <- "Four" +n_frameworks <- "fourteen" dcwf_ai_n <- 24 -other_ai_n <- 12 +other_wf_word <- "seven" +other_ai_n <- 18 +scywf_nice_n <- 766 +csta26_ai_n <- 34 nice_ethics_n <- "three" nice_total_fmt <- "2,225" -role_reuse_n <- "Three" +role_reuse_n <- "Four" role_once_n <- "Three" dcwf_all_roles_n <- 8 dcwf_role_n <- 74 cyq_max_roles_n <- 50 cyq_role_n <- 102 +scywf_all_roles_n <- 4 +scywf_role_n <- 40 if (DEMO_LIVE) { suppressPackageStartupMessages({ @@ -231,6 +253,8 @@ if (DEMO_LIVE) { dcwf_nm <- fw("^DCWF") nice_nm <- fw("^NICE") ccssf_nm <- fw("^Canadian") + csta26_nm <- fw("^2026 CSTA") + scywf_nm <- fw("^SCyWF") texts_of <- function(nm) value_parents$text[value_parents$framework_name == nm] shared_cq_dc <- intersect(texts_of(cyq_nm), texts_of(dcwf_nm)) @@ -244,6 +268,19 @@ if (DEMO_LIVE) { stopifnot(cq_nice_n == dc_nice_n) shared_nice_n <- cq_nice_n + # The prose ranks the two largest shared blocks. Fail the render if a + # re-ingest reorders them. + pair_counts <- value_parents |> + inner_join(value_parents, by = "text", suffix = c("", "_2"), + relationship = "many-to-many") |> + filter(framework_name < framework_name_2) |> + count(framework_name, framework_name_2, sort = TRUE) + stopifnot( + setequal(unlist(pair_counts[1, 1:2]), c(cyq_nm, dcwf_nm)), + setequal(unlist(pair_counts[2, 1:2]), c(nice_nm, scywf_nm)) + ) + scywf_nice_n <- length(intersect(texts_of(scywf_nm), nice_txt)) + ccssf_txt <- texts_of(ccssf_nm) other_txt <- unique(value_parents$text[value_parents$framework_name != ccssf_nm]) ccssf_pct_ceiling <- ceiling( @@ -270,8 +307,9 @@ if (DEMO_LIVE) { spelled <- function(n) { words <- c("one", "two", "three", "four", "five", "six", - "seven", "eight", "nine", "ten", "eleven") - if (n >= 1 && n <= 11) words[n] else format(n, big.mark = ",") + "seven", "eight", "nine", "ten", "eleven", "twelve", + "thirteen", "fourteen") + if (n >= 1 && n <= length(words)) words[n] else format(n, big.mark = ",") } # Sentence-initial figures are spelled out and capitalised. Spelled <- function(n) sub("^(.)", "\\U\\1", spelled(n), perl = TRUE) @@ -279,19 +317,31 @@ if (DEMO_LIVE) { ai_zero_n <- Spelled(length(ai_zero_names)) # The prose names the zero cases. Assert the named set, so a re-ingest # that moves a framework in or out fails the render instead of leaving - # a false sentence standing. + # a false sentence standing. DigComp 3.0 dropped out of this set + # 2026-09-21: its Learning Outcomes and Competence Statements name AI + # explicitly (the framework's own `ai_label` typing), where 2.2's 21 + # untruncated competence descriptions did not. stopifnot( setequal( - sort(sub("^(CCSSF|Canadian|CSEC2017|Cyber\\.org|DigComp|ECSF).*$", "\\1", + sort(sub("^(CCSSF|Canadian|CSEC2017|Cyber\\.org|ECSF).*$", "\\1", ai_zero_names)), - c("CSEC2017", "Canadian", "Cyber.org", "DigComp", "ECSF") + c("CSEC2017", "Canadian", "Cyber.org", "ECSF") ) ) n_frameworks <- spelled(nrow(topic_counts)) pick <- function(tbl, nm, col) tbl[[col]][tbl$framework_name == nm] dcwf_ai_n <- pick(topic_counts, dcwf_nm, "ai") - other_ai_n <- sum(topic_counts$ai) - dcwf_ai_n + # The prose compares DCWF with the other workforce frameworks, then gives + # the 2026 CSTA standards on their own. Fail the render if DCWF stops + # outnumbering the other workforce frameworks combined. + workforce_nm <- grep("^(NICE|SFIA|ECSF|CyQUAL|Canadian|Operational|SCyWF)", + topic_counts$framework_name, value = TRUE) + stopifnot(length(workforce_nm) == 7) + other_wf_word <- spelled(length(workforce_nm)) + other_ai_n <- sum(topic_counts$ai[topic_counts$framework_name %in% workforce_nm]) + stopifnot(dcwf_ai_n > other_ai_n) + csta26_ai_n <- pick(topic_counts, csta26_nm, "ai") nice_ethics_n <- spelled(pick(topic_counts, nice_nm, "ethics")) nice_total_fmt <- format(pick(topic_counts, nice_nm, "statements"), big.mark = ",") @@ -329,6 +379,16 @@ if (DEMO_LIVE) { value_roles$n_roles[value_roles$framework_name == dcwf_nm] == dcwf_role_n ) cyq_max_roles_n <- pick(reuse_summary, cyq_nm, "max_roles") + scywf_role_n <- pick(role_totals, scywf_nm, "n_roles_total") + scywf_all_roles_n <- sum( + value_roles$n_roles[value_roles$framework_name == scywf_nm] == scywf_role_n + ) + # The prose names the frameworks whose statements each belong to one role. + stopifnot(setequal( + sub("^(ECSF|Canadian|Operational).*$", "\\1", + reuse_summary$framework_name[reuse_summary$used_once]), + c("ECSF", "Canadian", "Operational") + )) } ``` @@ -370,7 +430,7 @@ statements |> -The largest block of shared text in the corpus is between CyQUAL, the Czech national framework, and DCWF, the US Department of Defense framework: `r cq_dc_fmt` statements, word for word. Neither names the other as a source. Each shares only `r shared_nice_n` statements with the current NICE Framework. What they have in common is the 2017 NICE Framework. Checked against NIST's 2017 release, nearly all of what the two share is 2017 NICE text, and under 5 percent of that 2017 text survives in NICE v2.2.0. The 2017 edition is better preserved in Prague and at the Pentagon than at NIST. Canada's framework describes itself as an adaptation of NICE and shares under `r ccssf_pct_ceiling` percent of its statements with anything. +The largest block of shared text in the corpus is between CyQUAL, the Czech national framework, and DCWF, the US Department of Defense framework: `r cq_dc_fmt` statements, word for word. Neither names the other as a source. Each shares only `r shared_nice_n` statements with the current NICE Framework. What they have in common is the 2017 NICE Framework. Checked against NIST's 2017 release, nearly all of what the two share is 2017 NICE text, and under `r nice2017_survival_pct` percent of that 2017 text survives in NICE v2.2.0. The 2017 edition is better preserved in Prague and at the Pentagon than at NIST. Canada's framework describes itself as an adaptation of NICE and shares under `r ccssf_pct_ceiling` percent of its statements with anything. The second-largest block belongs to Saudi Arabia's SCyWF, which shares `r scywf_nice_n` statements with NICE v2.2.0. ### Which frameworks have nothing to say about a topic? @@ -405,7 +465,7 @@ imap(topics, \(pattern, topic) { print(n = Inf) ``` -`r ai_zero_n` of the `r n_frameworks` frameworks contain no statement that mentions artificial intelligence or machine learning. DCWF has `r dcwf_ai_n` such statements, more than the other ten frameworks combined. NICE has `r nice_ethics_n` statements that mention ethics, out of `r nice_total_fmt`. SFIA, ECSF and DigComp have none that mention cryptography. +`r ai_zero_n` of the `r n_frameworks` frameworks contain no statement that mentions artificial intelligence or machine learning. DCWF has `r dcwf_ai_n` such statements, more than the other `r other_wf_word` workforce frameworks combined (`r other_ai_n`). The 2026 CSTA standards, which add a machine learning subconcept at every foundational level, have `r csta26_ai_n`. NICE has `r nice_ethics_n` statements that mention ethics, out of `r nice_total_fmt`. SFIA, ECSF and DigComp have none that mention cryptography. This measures the words a framework uses. It does not measure what a framework covers. OTCCF is a framework about operational technology and scores zero on the phrase "operational technology", because CSA writes "OT". A zero in this table is a reason to go and read the framework. @@ -427,7 +487,7 @@ role_element_bindings(rdf) |> arrange(desc(mean_roles)) ``` -`r role_reuse_n` of the role-based frameworks attach one statement to many roles. `r role_once_n` never do. In DCWF, `r dcwf_all_roles_n` statements are attached to all `r dcwf_role_n` work roles, which makes them a common core the framework does not name. In CyQUAL the most widely shared statement reaches `r cyq_max_roles_n` of `r cyq_role_n` roles. In ECSF, CCSSF and OTCCF every statement belongs to exactly one role, so "how many roles need this skill" cannot be answered from those frameworks' own data, and an element count does not show the difference. OTCCF's row counts the key tasks attached to its job roles. Its skill statements attach to skills. +`r role_reuse_n` of the role-based frameworks attach one statement to many roles. `r role_once_n` never do. In DCWF, `r dcwf_all_roles_n` statements are attached to all `r dcwf_role_n` work roles, which makes them a common core the framework does not name. In CyQUAL the most widely shared statement reaches `r cyq_max_roles_n` of `r cyq_role_n` roles. In SCyWF, `r scywf_all_roles_n` statements are attached to all `r scywf_role_n` job roles. In ECSF, CCSSF and OTCCF every statement belongs to exactly one role, so "how many roles need this skill" cannot be answered from those frameworks' own data, and an element count does not show the difference. OTCCF's row counts the key tasks attached to its job roles. Its skill statements attach to skills. ## Finding things with `cybedtools` @@ -439,7 +499,7 @@ The frameworks in the corpus were authored independently, for different audience ### US frameworks are about `r us_share_pct` percent of the corpus, and NICE's reach is wider than that -The corpus spans `r n_juris` jurisdictions and `r all_fmt` elements, counted with parsed examples included. US frameworks (`r us_fws`) contribute `r us_fmt` of them. The two EU-level frameworks (`r eu_fws`) contribute `r eu_fmt`, which reflects design intent: ECSF profiles point to e-CF 4.0 competences instead of restating them, and DigComp 2.2's annex examples are not yet extracted. Counting by jurisdiction understates how far one framework travels. Canada's framework describes itself as an adaptation of NICE for the Canadian labour market and cites NICE work roles throughout. CyQUAL says its structure and elements were adopted from NICE, and its task list carries the 2017 NICE task codes. Both describe the borrowing openly. The graph makes it countable. Element counts used as a coverage metric are evidence of lineage and design, and they do not measure how much work a framework represents. +The corpus spans `r n_juris` jurisdictions and `r all_fmt` elements, counted with parsed examples included. US frameworks (`r us_fws`) contribute `r us_fmt` of them. The two EU-level frameworks (`r eu_fws`) contribute `r eu_fmt`, which reflects design intent: ECSF profiles point to e-CF 4.0 competences instead of restating them, and DigComp is an intentionally high-level citizen self-assessment instrument. Counting by jurisdiction understates how far one framework travels. Canada's framework describes itself as an adaptation of NICE for the Canadian labour market and cites NICE work roles throughout. CyQUAL says its structure and elements were adopted from NICE, and its task list carries the 2017 NICE task codes. Both describe the borrowing openly. The graph makes it countable. Element counts used as a coverage metric are evidence of lineage and design, and they do not measure how much work a framework represents. ### Five NICE work roles carry a disproportionate share of the specification @@ -513,10 +573,10 @@ If you use cybedtools in published work, see [`CITATION.cff`](CITATION.cff) or r Package code is MIT (see [`LICENSE.md`](LICENSE.md)). Each framework retains its upstream license, and source text is not bundled. Users stage source files locally per [`docs/framework-data-sources.md`](docs/framework-data-sources.md), and each ingestion script writes a per-framework `provenance.yml`. See [`LICENSING.md`](LICENSING.md) for layered guidance on academic vs. commercial use. -Three frameworks here come on terms narrower than MIT. Two of them are represented by written permission of their stewards, and the Canadian framework is referenced as its steward asked. Those terms were given to cybedtools and do not pass to you. +Three frameworks here come on terms narrower than MIT, all represented by written permission of their stewards. Those terms were given to cybedtools and do not pass to you. - Derived from the Operational Technology Cybersecurity Competency Framework (OTCCF), published by the Cyber Security Agency of Singapore (CSA). Available at: CSA's permission covers the framework's structure, for non-commercial, academic and research use. -- Canadian Centre for Cyber Security, The Canadian Cyber Security Skills Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada. Referenced as the Canadian Centre for Cyber Security asked. +- Canadian Centre for Cyber Security, The Canadian Cyber Security Skills Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada. Used with permission of the Canadian Centre for Cyber Security. - CyQUAL, the Czech national cybersecurity qualifications framework, developed at Masaryk University. Open data, version 1.2.0, . ## Code of Conduct diff --git a/_pkgdown.yml b/_pkgdown.yml index a687b4c..0db0553 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -125,6 +125,16 @@ reference: - load_combined_ntriples_graph - make_demo_graph + - title: Data loading (public release) + desc: >- + Download and hash-verify the public per-framework data release into + the user cache, and load it into an rdf graph. The names and shape + match the Python package's cybed_fetch()/load_graph() (0.4.0 API + parity, see inst/conformance/). + contents: + - cybed_fetch + - load_graph + - title: SPARQL helpers desc: >- Single-BGP query primitives and domain-level helpers. The package's @@ -140,9 +150,19 @@ reference: - element_framework_bindings - subpoint_framework_bindings - example_framework_bindings + - unit_element_bindings - role_element_bindings + - unit_relation_bindings - element_text + - title: Similarity + desc: >- + Cross-framework unit-text similarity. The tokenizer, Jaccard scorer + and stopword list are internal; framework_similarity() is the one + exported entry point. + contents: + - framework_similarity + - title: Validation contents: - validate_jsonld_node diff --git a/concordance/LICENSE.md b/concordance/LICENSE.md index b9b9e78..a056ec1 100644 --- a/concordance/LICENSE.md +++ b/concordance/LICENSE.md @@ -30,15 +30,15 @@ The cybersecurity workforce and learning frameworks displayed on this site retai - **SFIA 9**: all use of SFIA is under licence from the SFIA Foundation, and the protected material is the concept, content and structure of SFIA. Redistribution and sub-licensing are prohibited at every tier. cybedtools uses SFIA for local analysis only, and this site shows SFIA skill names and aggregate counts and nothing more. A permission request to the SFIA Foundation is being prepared. - **Cyber.org K-12 v1.0**: Creative Commons BY-NC 4.0, attribution and non-commercial use. - **CSTA K-12 CS Standards (Rev 2017)**: Creative Commons BY-NC-SA 4.0, attribution, non-commercial use and share-alike. The ingested workbook carries no licence statement, so the label is read from CSTA's publication page for the 2017 edition. -- **DigComp 2.2**: Creative Commons BY 4.0, by the source PDF's own imprint page. The prescribed citation is: Vuorikari, R., Kluzer, S. and Punie, Y., DigComp 2.2: The Digital Competence Framework for Citizens, EUR 31006 EN, Publications Office of the European Union, Luxembourg, 2022, ISBN 978-92-76-48882-8, doi:10.2760/115376, JRC128415. +- **DigComp 3.0**: Creative Commons BY 4.0, verified against the dataset's own copyright notice, the JRC Data Catalogue record, and the DigComp 3.0 resources page. The European Commission logo is excluded from reuse; cybedtools reproduces no logo. The prescribed citation is: Cosgrove, J. and Cachia, R., DigComp 3.0: The Digital Competence Framework for Citizens, EUR 40491, Publications Office of the European Union, Luxembourg, 2025, ISBN 978-92-68-32677-0, doi:10.2760/0001149. Dataset: doi:10.2905/JRC.FR75K8R. - **ACM/IEEE CSEC2017**: copyright 2017 by ACM, IEEE, AIS, IFIP, all rights reserved, with permission granted only "to use these curricular guidelines for the development of educational materials and programs". This site shows Knowledge Area names, counts and alignment scores, and no statement text. A permission request to ACM is being prepared. ## Frameworks included by steward permission -Three frameworks are here on their stewards' terms. Two of them gave written permission, and the Canadian framework is referenced as its steward asked. Those terms were given to cybedtools and do not pass to you. +Three frameworks are here on their stewards' terms. All three gave written permission. Those terms were given to cybedtools and do not pass to you. - **CyQUAL 1.2.0**: CyQUAL, the Czech national cybersecurity qualifications framework, developed at Masaryk University. Open data, version 1.2.0, https://platform.cyqual.cz/. -- **CCSSF 2022**: Canadian Centre for Cyber Security, The Canadian Cyber Security Skills Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada. Referenced as the Canadian Centre for Cyber Security asked. +- **CCSSF 2022**: Canadian Centre for Cyber Security, The Canadian Cyber Security Skills Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada. Used with permission of the Canadian Centre for Cyber Security. - **OTCCF v1.1**: Derived from the Operational Technology Cybersecurity Competency Framework (OTCCF), published by the Cyber Security Agency of Singapore (CSA). Available at: CSA's permission covers the OTCCF's structure for non-commercial, academic and research use, and this site does not reproduce OTCCF statement text. diff --git a/concordance/_data-prep-unit-subtypes.R b/concordance/_data-prep-unit-subtypes.R new file mode 100644 index 0000000..9d3ea5b --- /dev/null +++ b/concordance/_data-prep-unit-subtypes.R @@ -0,0 +1,124 @@ +# Populates _data/unit_subtypes.rds: framework-specific structural +# sub-breakdowns that framework_summary does not carry (it counts +# cybed:OrganizingUnit / cybed:Role / cybed:RoleElement only, not a +# framework's own internal subtypes). Each figure below is queried +# directly off the combined graph so no framework page hand-types a +# structural sub-count. +# +# Run from the cybedtools repo root (here::here() quirk -- see +# concordance/_data-prep.R and memory/r-environment-quirks.md). + +suppressPackageStartupMessages({ + library(cybedtools) + library(dplyr) + library(here) +}) + +data_dir <- here("concordance", "_data") +dir.create(data_dir, showWarnings = FALSE, recursive = TRUE) + +graph_path <- here("data", "processed", "ntriples", "_combined.nt") +g <- load_combined_ntriples_graph(graph_path) + +q <- function(sparql) rdflib::rdf_query(g, sparql) + +n_of_type <- function(type_iri) { + nrow(q(sprintf("SELECT ?s WHERE { ?s a <%s> }", type_iri))) +} + +n_distinct_source_section <- function(type_iri) { + res <- q(sprintf( + "SELECT ?s ?sec WHERE { ?s a <%s> . ?s ?sec }", + type_iri + )) + if (nrow(res) == 0) return(character(0)) + sort(unique(res$sec)) +} + +# DigComp 3.0: Competence Areas and Competences, the two organizing-unit +# tiers beneath the framework (organizing_unit_count = both combined). +digcomp <- list( + competence_area_count = n_of_type("https://ec.europa.eu/jrc/digcomp/terms#CompetenceArea"), + competence_count = n_of_type("https://ec.europa.eu/jrc/digcomp/terms#Competence") +) + +# SCyWF 1.5: Category and Specialty Area, the two organizing-unit tiers +# above Job Role (organizing_unit_count = categories + specialty areas + +# job roles). +scywf <- list( + category_count = n_of_type("https://w3id.org/cybed/framework/scywf#Category"), + specialty_area_count = n_of_type("https://w3id.org/cybed/framework/scywf#SpecialtyArea") +) + +# CCSSF 2022: core roles (Annexes A-D, typed ccssf:WorkRole) versus +# cyber-adjacent roles (Annex E, typed ccssf:AdjacentRole). Both are +# cybed:Role and both count toward framework_summary's role_count. +# Activity-area count comes off the distinct cybed:sourceSection labels +# carried by the core (WorkRole) roles only -- adjacent roles carry the +# same field but are not organized into the four activity areas the way +# core roles are. +ccssf <- list( + core_role_count = n_of_type("https://w3id.org/cybed/framework/ccssf#WorkRole"), + adjacent_role_count = n_of_type("https://w3id.org/cybed/framework/ccssf#AdjacentRole"), + activity_area_count = length(n_distinct_source_section( + "https://w3id.org/cybed/framework/ccssf#WorkRole" + )) +) + +# OTCCF v1.1: the 30 Technical Skills and Competencies (second root, +# alongside job roles), how many of those are SkillsFuture-derived +# (cybed:sourceCategory tag), and how many career-map tracks the 15 job +# roles sit under (distinct cybed:sourceSection labels on JobRole nodes). +otccf_tsc_total <- n_of_type("https://w3id.org/cybed/framework/otccf#TechnicalSkillCompetency") +otccf_tsc_sf <- nrow(q(paste0( + "SELECT ?s WHERE { ", + "?s a . ", + "?s \"SkillsFuture ICT Framework\" }" +))) +otccf <- list( + tsc_count = otccf_tsc_total, + tsc_skillsfuture_count = otccf_tsc_sf, + track_count = length(n_distinct_source_section( + "https://w3id.org/cybed/framework/otccf#JobRole" + )) +) + +# SFIA 9: distinct proficiency levels in use across the ingested skill x +# level elements, read off the "SFIA Level " cybed:sourceSection +# literal rather than assumed from the published seven-level scale, so a +# level absent from the ingested set (as observed: Level 1 currently has +# no bound elements) is not silently asserted as present. +sfia_sections <- n_distinct_source_section("https://sfia-online.org/en/terms#Skill") +if (length(sfia_sections) == 0) { + # Skill nodes may not carry sourceSection themselves; pull levels from + # any sfia-namespaced subject's sourceSection instead (per-level + # elements do carry it -- see scripts/020-assemble-jsonld.R). + res <- q(paste0( + "SELECT ?s ?sec WHERE { ?s ?sec . ", + "FILTER(CONTAINS(STR(?sec), \"SFIA\")) }" + )) + sfia_sections <- if (nrow(res) == 0) character(0) else sort(unique(res$sec)) +} +sfia_levels <- sort(unique(as.integer(gsub(".*Level ", "", sfia_sections)))) +sfia <- list( + level_count = length(sfia_levels), + levels = sfia_levels +) + +# Cross-corpus count DCWF's own page compares itself against: "the other +# N frameworks" means the full corpus less DCWF itself. +n_other_frameworks <- nrow(cybedtools::framework_summary) - 1L + +unit_subtypes <- list( + digcomp = digcomp, + scywf = scywf, + ccssf = ccssf, + otccf = otccf, + sfia = sfia, + n_other_frameworks = n_other_frameworks +) + +saveRDS(unit_subtypes, file.path(data_dir, "unit_subtypes.rds")) + +cat("unit_subtypes.rds written:\n") +str(unit_subtypes) diff --git a/concordance/_includes/icons.html b/concordance/_includes/icons.html index 6ad2a42..2ecbfd7 100644 --- a/concordance/_includes/icons.html +++ b/concordance/_includes/icons.html @@ -99,6 +99,23 @@ + + SA + + + + + + + + + UK + + + + + + diff --git a/concordance/_publication-guard-test.R b/concordance/_publication-guard-test.R index 8b228eb..cc9b8d3 100644 --- a/concordance/_publication-guard-test.R +++ b/concordance/_publication-guard-test.R @@ -134,8 +134,11 @@ cat("\n=== text-publishable frameworks ===\n") print(text_publishable_frameworks()) df <- tibble( - framework = c("otccf", "ccssf", "cyqual", "nice"), - statement = c("otccf key task text", "ccssf task text", + # ccssf moved to full_with_attribution 2026-09-21 (owner decision, written + # permission from the Canadian Centre for Cyber Security); csec2017 takes + # its place here as the second structure_only example. + framework = c("otccf", "csec2017", "cyqual", "nice"), + statement = c("otccf key task text", "csec2017 task text", "cyqual task text", "nice task text") ) @@ -143,10 +146,10 @@ cat("\n=== drop_unpublishable_text ===\n") dropped <- drop_unpublishable_text(df, "framework", "statement") print(as.data.frame(dropped), right = FALSE) -if (!all(is.na(dropped$statement[dropped$framework %in% c("otccf", "ccssf")]))) { - bad("otccf and ccssf text blanked") +if (!all(is.na(dropped$statement[dropped$framework %in% c("otccf", "csec2017")]))) { + bad("otccf and csec2017 text blanked") } -ok("otccf and ccssf text blanked") +ok("otccf and csec2017 text blanked") if (!identical(dropped$statement[dropped$framework %in% c("cyqual", "nice")], df$statement[df$framework %in% c("cyqual", "nice")])) { bad("cyqual and nice text untouched") @@ -157,10 +160,10 @@ cat("\n=== assert_no_unpublishable_text on the undropped frame ===\n") err <- errmsg(assert_no_unpublishable_text(df, framework_col = "framework")) if (is.null(err)) bad("assert stops on unpublishable text") cat(err, "\n") -if (!grepl("otccf", err) || !grepl("ccssf", err)) { +if (!grepl("otccf", err) || !grepl("csec2017", err)) { bad("assert names both offending frameworks") } -ok("assert stops on unpublishable text and names otccf and ccssf") +ok("assert stops on unpublishable text and names otccf and csec2017") cat("\n=== assert_no_unpublishable_text after the drop ===\n") err <- errmsg(assert_no_unpublishable_text(dropped, framework_col = "framework")) diff --git a/concordance/_quarto.yml b/concordance/_quarto.yml index 449209a..f559d04 100644 --- a/concordance/_quarto.yml +++ b/concordance/_quarto.yml @@ -14,6 +14,7 @@ execute: message: false website: + llms-txt: true title: "Concordance" description: >- Cybersecurity Framework Concordance. Interactive views over a diff --git a/concordance/about.qmd b/concordance/about.qmd index bc0006a..5cb71fa 100644 --- a/concordance/about.qmd +++ b/concordance/about.qmd @@ -86,19 +86,19 @@ The frameworks displayed on this site are the work of their respective authoring - The Cyber Innovation Center and the Cyber.org K-12 standards team for the K-12 Cybersecurity Learning Standards. - The Computer Science Teachers Association for the K-12 CS Standards (Rev 2017). - The ACM, IEEE-CS, AIS SIGSEC, and IFIP WG 11.8 Joint Task Force for CSEC2017. -- Vuorikari, Kluzer, and Punie (Joint Research Centre, European Commission) for DigComp 2.2. +- Cosgrove and Cachia (Joint Research Centre, European Commission) for DigComp 3.0, building on the original framework authored by Vuorikari, Kluzer, and Punie. Three frameworks are here on their stewards' terms. Two of them gave written permission, and the Canadian framework is referenced as its steward asked. Each asked to be credited in a particular way. - CyQUAL, the Czech national cybersecurity qualifications framework, developed at Masaryk University. Open data, version 1.2.0, . Thanks to the CyQUAL team for confirming the open-data terms and for repairing the export so it could be used. -- Canadian Centre for Cyber Security, The Canadian Cyber Security Skills Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada. Referenced as the Canadian Centre for Cyber Security asked. Thanks to the Centre's Cyber Skills Development Team for supplying the full framework with its annexes. +- Canadian Centre for Cyber Security, The Canadian Cyber Security Skills Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada. Used with permission of the Canadian Centre for Cyber Security. Thanks to the Centre's Cyber Skills Development Team for supplying the full framework with its annexes. - Derived from the Operational Technology Cybersecurity Competency Framework (OTCCF), published by the Cyber Security Agency of Singapore (CSA). Available at: https://www.csa.gov.sg/resources/publications/operational-technology-cybersecurity-competency-framework--otccf-/ CSA's permission covers the OTCCF's structure, for non-commercial, academic and research use. This site shows OTCCF roles, skills, levels, and mappings, and does not reproduce its statement text. Terms for all three are in the package's [LICENSING](https://github.com/ryanstraight/cybedtools/blob/main/LICENSING.md) file. ## AI disclosure -The development and delivery of both cybedtools and Concordance was substantially accelerated by Anthropic's Claude Code. Creation was undertaken using the Opus 4.7 model under the author's direction. +The development and delivery of both cybedtools and Concordance was substantially accelerated by Anthropic's Claude Code, under the author's direction. ## License diff --git a/concordance/concepts/vocabulary.qmd b/concordance/concepts/vocabulary.qmd index ab37ac2..e42aa41 100644 --- a/concordance/concepts/vocabulary.qmd +++ b/concordance/concepts/vocabulary.qmd @@ -11,6 +11,9 @@ n_fw <- nrow(fs) role_slugs <- c("nice-v2", "dcwf-v5.1", "ecsf-v1", "cyqual-v1.2.0", "ccssf-2022", "otccf-v1.1") n_role_fw <- sum(fs$framework_slug %in% role_slugs) n_nonrole_fw <- n_fw - n_role_fw +u_sfia <- readRDS(here::here("_data", "unit_subtypes.rds"))$sfia +n_csec_ka <- fs$organizing_unit_count[fs$framework_slug == "csec2017-v1"] +n_ecsf_profiles <- fs$organizing_unit_count[fs$framework_slug == "ecsf-v1"] ``` ## The design problem @@ -19,10 +22,10 @@ n_nonrole_fw <- n_fw - n_role_fw what counts as a structural element. NICE has work roles, tasks, knowledge statements, and skill statements, plus competency areas as a second grouping axis. DigComp has competence -areas and proficiency levels. SFIA has skills and seven levels of +areas and proficiency levels. SFIA has skills and `r u_sfia$level_count` levels of responsibility. Cyber.org K-12 has grade-band-by-sub-concept cells plus prose clarification statements. CSTA has level-by-concept cells -plus practices. CSEC2017 has eight knowledge areas. ECSF has twelve +plus practices. CSEC2017 has `r n_csec_ka` knowledge areas. ECSF has `r n_ecsf_profiles` profiles. DCWF aligns to NICE work roles with overlap. CyQUAL has qualification units and proficiency levels. CCSSF has work roles and skills. OTCCF has job roles, skills, and role-to-role relations. diff --git a/concordance/frameworks/ccssf.qmd b/concordance/frameworks/ccssf.qmd new file mode 100644 index 0000000..23de10d --- /dev/null +++ b/concordance/frameworks/ccssf.qmd @@ -0,0 +1,156 @@ +--- +title: "CCSSF 2022" +subtitle: "Canadian Cyber Security Skills Framework" +jurisdiction: "CA" +framework-type: "Workforce" +framework-slug: "ccssf-2022" +license: "Government of Canada, with permission" +date: 2026-09-21 +categories: [workforce, ca] +--- + +## At a glance + +| | | +|---|---| +| Steward | Canadian Centre for Cyber Security | +| Canonical page | [Cyber Centre publications](https://www.cyber.gc.ca/en/publications) | +| Version | 2022 edition (ITSM.00.039) | +| Source format | PDF, supplied directly by the Cyber Centre with Annexes A through F | +| License | Copyright Government of Canada, no licence notice in the document itself. Used with the Canadian Centre for Cyber Security's written permission for full-text publication with attribution | +| `framework_summary` slug | `ccssf-2022` | + +## Structure in cybed: terms + +::: {.column-page} + +```{=html} +
+ + + CCSSF structural mapping + + + + + + + + + Framework + ccssf:Framework + + + + + Work role + (22 core roles across 4 activity areas, plus adjacent roles) + ccssf:WorkRole + = cybed:Role = cybed:OrganizingUnit + + + + + Task, Competency, Tool/Technology + = cybed:RoleElement + + + + contains + cybed:hasOrganizingUnit + + + has element + cybed:hasElement + + +
Click any box or edge label to flip between plain English and the technical schema name.
+
+``` + +::: + +## Counts + +```{r} +#| label: setup +#| include: false +n <- cybedtools::framework_summary +n <- as.list(n[n$framework_slug == "ccssf-2022", ]) +u <- readRDS(here::here("_data", "unit_subtypes.rds"))$ccssf +cm <- function(x) format(x, big.mark = ",") +d1 <- function(x) format(round(x, 1), nsmall = 1) +``` + +```{r} +#| label: tbl-counts +tibble::tibble( + Measure = c( + "Work roles", + "Strict elements", + "Subpoints (parsed enumeration lists)", + "With-examples elements", + "Elements per work role, strict", + "Elements per work role, with-examples" + ), + Value = c( + cm(n$role_count), + cm(n$element_count_strict), + cm(n$subpoint_count), + cm(n$element_count_with_examples), + d1(n$elements_per_role_strict), + d1(n$elements_per_role_with_examples) + ) +) |> + knitr::kable() +``` + +`r cm(u$core_role_count)` core cyber security roles sit under +`r cm(u$activity_area_count)` activity areas (Annexes A through D: +Oversee & govern, Design & develop, Operate & maintain, Protect & +defend). Annex E's `r cm(u$adjacent_role_count)` cyber-adjacent roles +add further work roles from a single wide table rather than a +per-role block. Annex F, a talent-alliance membership list, is not a +role structure and is not ingested. + +## Provenance + +::: {.provenance-cards .column-page} + +::: {.provenance-card} + + +**Source** + +The Canadian Cyber Security Skills Framework (ITSM.00.039), 2022 edition, supplied directly by the Cyber Centre's Cyber Skills Development Team with its full annexes. +::: + +::: {.provenance-card} + + +**Ingestion** + +`scripts/010-ingest-ccssf.R` parses each role block's field labels (Functional description, Tasks, Competencies, Tools & technology, and the rest) and the Annex E table into the cybed schema. +::: + +::: {.provenance-card} + + +**License** + +The document carries no copyright line, Creative Commons statement, Open Government Licence reference, or all-rights-reserved statement of its own; it carries only Government of Canada publication identifiers and TLP:CLEAR / UNCLASSIFIED disclosure markings, which are not licences. The Canadian Centre for Cyber Security gave cybedtools written permission on 2026-09-21 for full-text publication with attribution. +::: + +::: + +## Caveats + +- CCSSF is an **explicit adaptation of the US NICE Workforce Framework for Cybersecurity**, and cites NICE work-role IDs throughout. Those citations are captured as their own crosswalk table rather than folded into the element counts above. +- Each core role's field labels (Development pathway, Consequence of error or risk, Future trends affecting key competencies, and the rest) are **preserved as the document's own vocabulary**, not normalised into cybed's generic element types beyond the Task/Competency/Tool-or-Technology split shown here. +- Annex F is **a membership list, not a role structure**, and carries no framework content of its own. + +## Related + +- [NICE Framework v2](nice-v2.qmd), the framework CCSSF explicitly adapts. +- [Cross-framework density spread](../queries/density-spread.qmd) +- [The vocabulary](../concepts/vocabulary.qmd) diff --git a/concordance/frameworks/csec2017.qmd b/concordance/frameworks/csec2017.qmd index fbf04e5..53d59c0 100644 --- a/concordance/frameworks/csec2017.qmd +++ b/concordance/frameworks/csec2017.qmd @@ -3,8 +3,7 @@ title: "ACM/IEEE CSEC2017" subtitle: "Cybersecurity Curricula 2017" jurisdiction: "Global" framework-type: "Pedagogy" -organizing-units: 8 -elements: 40 +framework-slug: "csec2017-v1" license: "ACM/IEEE educational use" date: 2026-05-08 categories: [pedagogy, global] @@ -76,14 +75,37 @@ categories: [pedagogy, global] ## Counts -| Measure | Value | -|---|---| -| Knowledge Areas | 8 | -| Strict elements (topics) | 38 | -| Subpoints (parsed enumeration lists) | 2 | -| With-examples elements | 40 | -| Topics per Knowledge Area, strict | 4.8 | -| Topics per Knowledge Area, with-examples | 5.0 | +```{r} +#| label: setup +#| include: false +n <- cybedtools::framework_summary +n <- as.list(n[n$framework_slug == "csec2017-v1", ]) +cm <- function(x) format(x, big.mark = ",") +d1 <- function(x) format(round(x, 1), nsmall = 1) +``` + +```{r} +#| label: tbl-counts +tibble::tibble( + Measure = c( + "Knowledge Areas", + "Strict elements (topics)", + "Subpoints (parsed enumeration lists)", + "With-examples elements", + "Topics per Knowledge Area, strict", + "Topics per Knowledge Area, with-examples" + ), + Value = c( + cm(n$organizing_unit_count), + cm(n$element_count_strict), + cm(n$subpoint_count), + cm(n$element_count_with_examples), + d1(n$elements_per_organizing_unit_strict), + d1(n$elements_per_organizing_unit_with_examples) + ) +) |> + knitr::kable() +``` The Knowledge Area / Topic structure is the framework's coarse-grained surface. Beneath each topic, CSEC2017 defines further diff --git a/concordance/frameworks/csta-2017.qmd b/concordance/frameworks/csta-2017.qmd index dc1044a..8460e76 100644 --- a/concordance/frameworks/csta-2017.qmd +++ b/concordance/frameworks/csta-2017.qmd @@ -3,8 +3,7 @@ title: "CSTA K-12 CS Standards" subtitle: "Computer Science Teachers Association K-12 Standards (Revised 2017)" jurisdiction: "US" framework-type: "Pedagogy" -organizing-units: 25 -elements: 258 +framework-slug: "csta-2017" license: "CC BY-NC-SA 4.0" date: 2026-05-08 categories: [pedagogy, us] @@ -89,15 +88,39 @@ categories: [pedagogy, us] ## Counts -| Measure | Value | -|---|---| -| Standard groups | 25 | -| Strict elements (numbered standards) | 120 | -| Subpoints (parsed enumeration lists) | 24 | -| Examples (clarification text) | 114 | -| With-examples elements | 258 | -| Elements per standard group (strict) | 4.8 | -| Elements per standard group (with examples) | 10.3 | +```{r} +#| label: setup +#| include: false +n <- cybedtools::framework_summary +n <- as.list(n[n$framework_slug == "csta-2017", ]) +cm <- function(x) format(x, big.mark = ",") +d1 <- function(x) format(round(x, 1), nsmall = 1) +``` + +```{r} +#| label: tbl-counts +tibble::tibble( + Measure = c( + "Standard groups", + "Strict elements (numbered standards)", + "Subpoints (parsed enumeration lists)", + "Examples (clarification text)", + "With-examples elements", + "Elements per standard group (strict)", + "Elements per standard group (with examples)" + ), + Value = c( + cm(n$organizing_unit_count), + cm(n$element_count_strict), + cm(n$subpoint_count), + cm(n$example_count), + cm(n$element_count_with_examples), + d1(n$elements_per_organizing_unit_strict), + d1(n$elements_per_organizing_unit_with_examples) + ) +) |> + knitr::kable() +``` Per-unit density approximately doubles when clarification text is included alongside numbered standards. @@ -134,7 +157,7 @@ Creative Commons BY-NC-SA 4.0, non-commercial use with attribution and share-ali ## Caveats -- CSTA's seven core practices sit in the framework's high-level structure but are **not represented as per-cell numbered elements**. They run alongside the level-by-concept enumeration rather than within it. +- CSTA's core practices sit in the framework's high-level structure but are **not represented as per-cell numbered elements**, and so are not ingested or counted. They run alongside the level-by-concept enumeration rather than within it. - Some clarifications are **constraining specifications and others are illustrative**. The package treats all clarifications uniformly as `cybed:Example` nodes. Analyses that need to distinguish prescriptive scaffolding from illustrative scaffolding will need to read the source text. - CSTA's revised 2017 standards **predate the K-12 cybersecurity push** and center general computer science. Cybersecurity content appears within Networks and the Internet and within Impacts of Computing rather than as a dedicated concept area, which is one of the gaps Cyber.org K-12 was authored to fill. diff --git a/concordance/frameworks/csta-2026.qmd b/concordance/frameworks/csta-2026.qmd new file mode 100644 index 0000000..51f7b73 --- /dev/null +++ b/concordance/frameworks/csta-2026.qmd @@ -0,0 +1,155 @@ +--- +title: "CSTA PK-12 CS (2026)" +subtitle: "2026 CSTA PK-12 Computer Science Standards" +jurisdiction: "US" +framework-type: "Pedagogy" +framework-slug: "csta-2026" +license: "CC BY-NC-SA 4.0" +date: 2026-09-21 +categories: [pedagogy, us] +--- + +## At a glance + +| | | +|---|---| +| Steward | Computer Science Teachers Association (CSTA) | +| Canonical page | [CSTA PK-12 CS Standards](https://csteachers.org/pk12standards/) | +| Version | 2026 edition | +| Released | 2026 | +| Source format | JSON behind CSTA's interactive Standards Explorer, plus the standards PDF | +| License | Creative Commons BY-NC-SA 4.0, attribution, non-commercial use, share-alike, by the PDF's own licence page | +| `framework_summary` slug | `csta-2026` | + +## Structure in cybed: terms + +::: {.column-page} + +```{=html} +
+ + + CSTA PK-12 CS (2026) structural mapping + + + + + + + + + Framework + csta2026:Framework + + + + + Standard group + (level x concept, or level x specialty area) + csta2026:StandardGroup + = cybed:OrganizingUnit + (no cybed:Role) + + + + + Standard + csta2026:Standard + = cybed:RoleElement + + + + contains + cybed:hasOrganizingUnit + + + has element + cybed:hasElement + + +
Foundational-tier groups key on level x concept x subconcept; specialty-tier groups key on level x specialty area x subarea. Click any box or edge label to flip between plain English and the technical schema name.
+
+``` + +::: + +## Counts + +```{r} +#| label: setup +#| include: false +n <- cybedtools::framework_summary +n <- as.list(n[n$framework_slug == "csta-2026", ]) +cm <- function(x) format(x, big.mark = ",") +d1 <- function(x) format(round(x, 1), nsmall = 1) +``` + +```{r} +#| label: tbl-counts +tibble::tibble( + Measure = c( + "Standard groups", + "Strict elements (standards)", + "With-examples elements", + "Elements per standard group, strict", + "Elements per standard group, with-examples" + ), + Value = c( + cm(n$organizing_unit_count), + cm(n$element_count_strict), + cm(n$element_count_with_examples), + d1(n$elements_per_organizing_unit_strict), + d1(n$elements_per_organizing_unit_with_examples) + ) +) |> + knitr::kable() +``` + +Each standard carries boundary-statement paragraphs and implementation +examples, both promoted to `cybed:Example` nodes, which is most of the +gap between the strict and with-examples counts. Practices, +dispositions, progressions, and interdisciplinary connections are +staged in the source JSON but not yet carried into the graph. + +## Provenance + +::: {.provenance-cards .column-page} + +::: {.provenance-card} + + +**Source** + +The JSON behind CSTA's official interactive Standards Explorer for the 2026 PK-12 CS Standards, plus the standards PDF. +::: + +::: {.provenance-card} + + +**Ingestion** + +`scripts/010-ingest-csta2026.R` normalises the foundational and specialty tiers onto one (level, concept) unit scheme and parses the structure into the cybed schema. +::: + +::: {.provenance-card} + + +**License** + +Creative Commons BY-NC-SA 4.0, by the source PDF's own licence page: attribution, non-commercial use, and share-alike for any derivation. The share-alike term propagates to any file containing CSTA-derived content. The package does not bundle the source. Users stage it locally. +::: + +::: + +## Caveats + +- csta-2026 is a **framework of its own, not a revision of the 2017 edition**. The two editions share no identifier scheme, and CSTA's own crosswalk between them is many-to-many. Cross-version comparisons need the crosswalk, not identifier matching. +- The **share-alike term is the strictest licence condition in the corpus**. Any derivative work containing csta-2026-derived content must itself be shared under CC BY-NC-SA 4.0. +- Foundational and specialty tiers use **different grouping logic** (concept/subconcept versus specialty area/subarea), normalised here onto one `csta2026:StandardGroup` type. Analyses that care about the distinction should filter on the source tier field. + +## Related + +- [CSTA K-12 CS (Rev 2017)](csta-2017.qmd), the prior edition, kept + side by side rather than replaced in place. +- [Cross-framework density spread](../queries/density-spread.qmd) +- [The vocabulary](../concepts/vocabulary.qmd) diff --git a/concordance/frameworks/cyberorg-k12.qmd b/concordance/frameworks/cyberorg-k12.qmd index 570e305..d89f925 100644 --- a/concordance/frameworks/cyberorg-k12.qmd +++ b/concordance/frameworks/cyberorg-k12.qmd @@ -3,8 +3,7 @@ title: "Cyber.org K-12" subtitle: "K-12 Cybersecurity Learning Standards" jurisdiction: "US" framework-type: "Pedagogy" -organizing-units: 116 -elements: 492 +framework-slug: "cyberorg-k12-v1.0" license: "CC BY-NC 4.0" date: 2026-05-08 categories: [pedagogy, us] @@ -89,14 +88,37 @@ categories: [pedagogy, us] ## Counts -| Measure | Value | -|---|---| -| Standard groups | 116 | -| Strict elements (numbered standards) | 123 | -| With-examples elements | 492 | -| Examples (clarification statements) | 369 | -| Elements per standard group (strict) | 1.1 | -| Elements per standard group (with examples) | 4.2 | +```{r} +#| label: setup +#| include: false +n <- cybedtools::framework_summary +n <- as.list(n[n$framework_slug == "cyberorg-k12-v1.0", ]) +cm <- function(x) format(x, big.mark = ",") +d1 <- function(x) format(round(x, 1), nsmall = 1) +``` + +```{r} +#| label: tbl-counts +tibble::tibble( + Measure = c( + "Standard groups", + "Strict elements (numbered standards)", + "With-examples elements", + "Examples (clarification statements)", + "Elements per standard group (strict)", + "Elements per standard group (with examples)" + ), + Value = c( + cm(n$organizing_unit_count), + cm(n$element_count_strict), + cm(n$element_count_with_examples), + cm(n$example_count), + d1(n$elements_per_organizing_unit_strict), + d1(n$elements_per_organizing_unit_with_examples) + ) +) |> + knitr::kable() +``` The strict count reflects only numbered standards. The with-examples count promotes each Clarification statement to a diff --git a/concordance/frameworks/cybok.qmd b/concordance/frameworks/cybok.qmd new file mode 100644 index 0000000..fad179a --- /dev/null +++ b/concordance/frameworks/cybok.qmd @@ -0,0 +1,156 @@ +--- +title: "CyBOK v1.1.0" +subtitle: "Cyber Security Body of Knowledge" +jurisdiction: "UK" +framework-type: "Pedagogy" +framework-slug: "cybok-v1.1.0" +license: "OGL v3.0" +date: 2026-09-21 +categories: [pedagogy, uk] +--- + +## At a glance + +| | | +|---|---| +| Steward | National Cyber Security Centre (NCSC) | +| Canonical page | [Cyber Security Body of Knowledge](https://www.cybok.org/) | +| Version | 1.1.0 | +| Released | 2021-07 | +| Source format | PDF only (Introduction, per-Knowledge-Area trees, and an alphabetical index) | +| License | Crown Copyright, The National Cyber Security Centre 2021, licensed under the Open Government Licence v3.0 | +| `framework_summary` slug | `cybok-v1.1.0` | + +## Structure in cybed: terms + +::: {.column-page} + +```{=html} +
+ + + CyBOK structural mapping + + + + + + + + + Framework + cybok:Framework + + + + + Knowledge Area + (21 KAs across 5 categories) + cybok:KnowledgeArea + = cybed:OrganizingUnit + (no cybed:Role) + + + + + Topic / Indicative Material + cybok:Topic, cybok:IndicativeMaterial = cybed:RoleElement + + + + contains + cybed:hasOrganizingUnit + + + has element + cybed:hasElement + + +
Topics come one layer below the Knowledge Tree's root; Indicative Material comes one layer further down, from a tree structure read off each Knowledge Tree diagram rather than from tabular data. Click any box or edge label to flip between plain English and the technical schema name.
+
+``` + +::: + +## Counts + +```{r} +#| label: setup +#| include: false +n <- cybedtools::framework_summary +n <- as.list(n[n$framework_slug == "cybok-v1.1.0", ]) +cm <- function(x) format(x, big.mark = ",") +d1 <- function(x) format(round(x, 1), nsmall = 1) +``` + +```{r} +#| label: tbl-counts +tibble::tibble( + Measure = c( + "Knowledge Areas", + "Strict elements (Topics)", + "Subpoints (Indicative Material, parsed from Knowledge Trees)", + "With-examples elements", + "Elements per Knowledge Area, strict", + "Elements per Knowledge Area, with-examples" + ), + Value = c( + cm(n$organizing_unit_count), + cm(n$element_count_strict), + cm(n$subpoint_count), + cm(n$element_count_with_examples), + d1(n$elements_per_organizing_unit_strict), + d1(n$elements_per_organizing_unit_with_examples) + ) +) |> + knitr::kable() +``` + +CyBOK publishes no CSV, JSON, or XML: every count here comes from +reading a Knowledge Tree as a drawing. Node labels come from the PDF +text layer; parent-child links come from the drawing itself, recovered +by blanking out each node box and tracing the remaining ink. The gap +between the strict and with-examples counts reflects how much of +CyBOK's specification depth sits in Indicative Material terms one +layer below the Topic level, rather than in the Topics themselves. + +## Provenance + +::: {.provenance-cards .column-page} + +::: {.provenance-card} + + +**Source** + +The Introduction to CyBOK Knowledge Area (for KA names and categories), one Knowledge Tree PDF per KA (for Topics and Indicative Material), and the alphabetical Indicative Material index (for KA acronyms and cross-checking). +::: + +::: {.provenance-card} + + +**Ingestion** + +`scripts/010-ingest-cybok.R` renders each Knowledge Tree page, blanks out the node boxes, and reconstructs the tree from the remaining connecting ink plus the PDF's own text-layer word coordinates for labels. +::: + +::: {.provenance-card} + + +**License** + +Crown Copyright, The National Cyber Security Centre 2021, licensed under the Open Government Licence v3.0, which permits copying, publishing, adapting, and commercial use on condition of attribution. Every source document (the Introduction, each Knowledge Tree, and the A-to-Z index) carries the same notice. +::: + +::: + +## Caveats + +- Knowledge Tree parsing is **drawing recovery, not text extraction**. A KA whose tree uses an unusual layout is the most likely source of a structural error; cross-checking against the alphabetical index catches most but not all of these. +- CyBOK defines its own vocabulary for tree depth (**Topic**, then **Indicative Material** one layer further down); deeper layers in a given tree, where they exist, are not separately typed. +- Case is **preserved exactly as printed** in each source: Knowledge Trees mostly print Topics in lower case, and the A-to-Z index prints everything in capitals. Matching a Topic name across the two requires case-insensitive comparison. + +## Related + +- [Cross-framework density spread](../queries/density-spread.qmd) +- [The vocabulary](../concepts/vocabulary.qmd) diff --git a/concordance/frameworks/cyqual.qmd b/concordance/frameworks/cyqual.qmd new file mode 100644 index 0000000..f462c72 --- /dev/null +++ b/concordance/frameworks/cyqual.qmd @@ -0,0 +1,168 @@ +--- +title: "CyQUAL 1.2.0" +subtitle: "Czech Republic National Cybersecurity Qualifications Framework" +jurisdiction: "CZ" +framework-type: "Workforce" +framework-slug: "cyqual-v1.2.0" +license: "Open data, attribution required" +date: 2026-09-21 +categories: [workforce, cz] +--- + +## At a glance + +| | | +|---|---| +| Steward | CyQUAL, developed at Masaryk University | +| Canonical page | [CyQUAL platform](https://platform.cyqual.cz/) | +| Version | 1.2.0 | +| Source format | Open-data JSON export | +| License | Published as open data. Written confirmation from the CyQUAL team permits a derived RDF/JSON-LD graph, on condition of attribution to CyQUAL and to Masaryk University | +| `framework_summary` slug | `cyqual-v1.2.0` | + +## Structure in cybed: terms + +::: {.column-page} + +```{=html} +
+ + + CyQUAL structural mapping + + + + + + + + + + Framework + cyqual:Framework + + + + + Category, specialization area + (two grouping tiers above the role) + = cybed:OrganizingUnit + + + + + Work role + cyqual:WorkRole + = cybed:Role = cybed:OrganizingUnit + + + + + Task, Requirement, Competency + = cybed:RoleElement + + + + contains + cybed:hasOrganizingUnit + + + contains + cybed:hasOrganizingUnit + + + has element + cybed:hasElement + + +
Category and specialization area nest above the work role; tasks and requirements (the latter classified by a separate competency/competency-group axis) attach below it. Click any box or edge label to flip between plain English and the technical schema name.
+
+``` + +::: + +## Counts + +```{r} +#| label: setup +#| include: false +n <- cybedtools::framework_summary +n <- as.list(n[n$framework_slug == "cyqual-v1.2.0", ]) +cm <- function(x) format(x, big.mark = ",") +d1 <- function(x) format(round(x, 1), nsmall = 1) +``` + +```{r} +#| label: tbl-counts +tibble::tibble( + Measure = c( + "Organizing units (categories, specialization areas, work roles)", + "Work roles", + "Strict elements (tasks and requirements)", + "Subpoints (parsed enumeration lists)", + "With-examples elements", + "Elements per organizing unit, strict", + "Elements per work role, strict", + "Elements per work role, with-examples" + ), + Value = c( + cm(n$organizing_unit_count), + cm(n$role_count), + cm(n$element_count_strict), + cm(n$subpoint_count), + cm(n$element_count_with_examples), + d1(n$elements_per_organizing_unit_strict), + d1(n$elements_per_role_strict), + d1(n$elements_per_role_with_examples) + ) +) |> + knitr::kable() +``` + +The source hierarchy is category to specialization area to work role, +with tasks and requirements attached to work roles through two join +tables that carry no proficiency level, weight, or mandatory flag: +every association is an unqualified membership edge. Requirements are +additionally classified by a competency and competency-group axis +that sits alongside, not beneath, the work-role hierarchy. + +## Provenance + +::: {.provenance-cards .column-page} + +::: {.provenance-card} + + +**Source** + +CyQUAL's official open-data JSON export, version 1.2.0, retrieved 2026-09-16. +::: + +::: {.provenance-card} + + +**Ingestion** + +`scripts/010-ingest-cyqual.R` flattens seven entity arrays and two association arrays into tidy tables and parses the structure into the cybed schema. +::: + +::: {.provenance-card} + + +**License** + +The open-data JSON export carries no rights field of any kind. The CyQUAL team at Masaryk University confirmed in writing on 2026-09-16 that the framework is published as open data and publicly available, that a derived RDF/JSON-LD graph may be built from it, and that the only condition is attribution to CyQUAL and to Masaryk University. +::: + +::: + +## Caveats + +- CyQUAL is ingested from its **English-language export when available**; the Czech export is used otherwise. No translation happens in the pipeline itself. Provenance records which language a given ingestion run used. +- **Neither join table carries a strength-of-relationship signal.** A task or requirement attached to a work role is a membership edge only; nothing in the source distinguishes a core task from a peripheral one. +- Organizing-unit counts mix **three structurally different tiers** (category, specialization area, work role). Analyses comparing "organizing units" across frameworks should filter to `cyqual:WorkRole` for a role-level comparison. + +## Related + +- [Cross-framework density spread](../queries/density-spread.qmd) +- [The vocabulary](../concepts/vocabulary.qmd) diff --git a/concordance/frameworks/dcwf-v51.qmd b/concordance/frameworks/dcwf-v51.qmd index 5a0af31..54ed39b 100644 --- a/concordance/frameworks/dcwf-v51.qmd +++ b/concordance/frameworks/dcwf-v51.qmd @@ -3,8 +3,7 @@ title: "DCWF v5.1" subtitle: "DoD Cyber Workforce Framework (DoDD 8140)" jurisdiction: "US" framework-type: "Workforce" -organizing-units: 74 -elements: 4052 +framework-slug: "dcwf-v5.1" license: "Public domain" date: 2026-05-08 categories: [workforce, us] @@ -21,7 +20,7 @@ categories: [workforce, us] | Aligned to | NICE Framework | | Source format | DoD CIO Work Role Tool (XLSX) | | License | A US Government work prepared by DoD personnel, so not subject to copyright in the United States under 17 U.S.C. 105. The v5.1 workbook carries no distribution statement | -| `framework_summary` slug | `dcwf-v51` | +| `framework_summary` slug | `dcwf-v5.1` | ## Structure in cybed: terms @@ -84,21 +83,45 @@ categories: [workforce, us] ## Counts -| Measure | Value | -|---|---| -| Work roles | 74 | -| Strict elements | 2,945 | -| Subpoints (parsed enumeration lists) | 1,107 | -| With-examples elements | 4,052 | -| Elements per work role, strict | 39.8 | -| Elements per work role, with-examples | 54.8 | +```{r} +#| label: setup +#| include: false +n <- cybedtools::framework_summary +n <- as.list(n[n$framework_slug == "dcwf-v5.1", ]) +u <- readRDS(here::here("_data", "unit_subtypes.rds"))$n_other_frameworks +cm <- function(x) format(x, big.mark = ",") +d1 <- function(x) format(round(x, 1), nsmall = 1) +``` + +```{r} +#| label: tbl-counts +tibble::tibble( + Measure = c( + "Work roles", + "Strict elements", + "Subpoints (parsed enumeration lists)", + "With-examples elements", + "Elements per work role, strict", + "Elements per work role, with-examples" + ), + Value = c( + cm(n$role_count), + cm(n$element_count_strict), + cm(n$subpoint_count), + cm(n$element_count_with_examples), + d1(n$elements_per_role_strict), + d1(n$elements_per_role_with_examples) + ) +) |> + knitr::kable() +``` DCWF has no `cybed:Example` nodes -- its numbered statements carry no separate prose-embedded clarifications the way Cyber.org K-12 or CSTA -do. But 1,107 of its Task/KSA statements phrase part of their content +do. But `r cm(n$subpoint_count)` of its Task/KSA statements phrase part of their content as an enumerated list ("including X and Y", "(e.g., X, Y, and Z)"), and the uniform Subpoint parser splits those out the same way it does -across the other ten frameworks, so strict and with-examples do not +across the other `r cm(u)` frameworks in the corpus, so strict and with-examples do not coincide here. This is DCWF's second correction of the day: a parser bug fixed 2026-08-14 (a dead `e.g.` introducer pattern, plus a soft-wrap newline being read as a false sentence boundary) meant most diff --git a/concordance/frameworks/digcomp-22.qmd b/concordance/frameworks/digcomp-30.qmd similarity index 52% rename from concordance/frameworks/digcomp-22.qmd rename to concordance/frameworks/digcomp-30.qmd index f6ba3f3..180b74c 100644 --- a/concordance/frameworks/digcomp-22.qmd +++ b/concordance/frameworks/digcomp-30.qmd @@ -1,12 +1,11 @@ --- -title: "DigComp 2.2" +title: "DigComp 3.0" subtitle: "European Digital Competence Framework for Citizens" jurisdiction: "EU" framework-type: "Pedagogy" -organizing-units: 5 -elements: 21 -license: "EU re-use terms" -date: 2026-05-08 +framework-slug: "digcomp-3.0" +license: "CC BY 4.0" +date: 2026-09-21 categories: [pedagogy, eu] --- @@ -16,12 +15,12 @@ categories: [pedagogy, eu] |---|---| | Steward | Joint Research Centre, European Commission | | Canonical page | [DigComp at the Joint Research Centre](https://joint-research-centre.ec.europa.eu/digcomp_en) | -| Version | 2.2 | -| Released | 2022-03-17 | -| Authors | Vuorikari, Kluzer, and Punie | -| Source format | PDF (JRC publication JRC128415) | -| License | Creative Commons BY 4.0, by the source PDF's own imprint page | -| `framework_summary` slug | `digcomp-2.2` | +| Version | 3.0, fifth edition | +| Released | 2025-11-27 | +| Authors | Cosgrove and Cachia | +| Source format | JSON-LD and XLSX data supplement (JRC144121) | +| License | Creative Commons BY 4.0, verified against the dataset's own copyright notice, the JRC Data Catalogue record, and the DigComp 3.0 resources page | +| `framework_summary` slug | `digcomp-3.0` | ## Structure in cybed: terms @@ -31,7 +30,7 @@ categories: [pedagogy, eu]
- DigComp 2.2 structural mapping + DigComp 3.0 structural mapping @@ -57,14 +56,13 @@ categories: [pedagogy, eu] Competence digcomp:Competence - = cybed:RoleElement + (also cybed:OrganizingUnit) - + - Proficiency descriptor - digcomp:ProficiencyDescriptor - = cybed:RoleElement + Statement / outcome + = cybed:RoleElement / cybed:Example @@ -72,15 +70,15 @@ categories: [pedagogy, eu] cybed:hasOrganizingUnit - has element - cybed:hasElement + contains + cybed:hasOrganizingUnit - - has element - cybed:hasElement + + has element / example + cybed:hasElement / hasExample -
DigComp nests three levels. Areas contain competences, and competences carry proficiency descriptors. Click any box or edge label to flip between plain English and the technical schema name.
+
DigComp 3.0 nests competences inside areas, both as organizing units. Competence Statements attach as elements; Learning Outcomes attach as examples. Click any box or edge label to flip between plain English and the technical schema name.
``` @@ -88,19 +86,53 @@ categories: [pedagogy, eu] ## Counts -| Measure | Value | -|---|---| -| Competence Areas | 5 | -| Strict elements | 21 | -| With-examples elements | 21 | -| Elements per Competence Area | 4.2 | - -DigComp 2.2's signature update was a set of per-competence Annex -examples illustrating each competence under emerging technologies, -AI in particular. Those Annex examples are not extracted as -queryable nodes. The strict count reflects only the Competence and -ProficiencyDescriptor enumeration, so element-density figures here -materially understate what the publication actually contains. +```{r} +#| label: setup +#| include: false +n <- cybedtools::framework_summary +n <- as.list(n[n$framework_slug == "digcomp-3.0", ]) +u <- readRDS(here::here("_data", "unit_subtypes.rds"))$digcomp +cm <- function(x) format(x, big.mark = ",") +d1 <- function(x) format(round(x, 1), nsmall = 1) +``` + +```{r} +#| label: tbl-counts +tibble::tibble( + Measure = c( + "Competence Areas", + "Competences", + "Organizing units (areas + competences)", + "Strict elements (Competence Statements)", + "Examples (Learning Outcomes, post-errata)", + "With-examples elements", + "Elements per organizing unit, strict", + "Elements per organizing unit, with-examples" + ), + Value = c( + cm(u$competence_area_count), + cm(u$competence_count), + cm(n$organizing_unit_count), + cm(n$element_count_strict), + cm(n$example_count), + cm(n$element_count_with_examples), + d1(n$elements_per_organizing_unit_strict), + d1(n$elements_per_organizing_unit_with_examples) + ) +) |> + knitr::kable() +``` + +DigComp 3.0 replaces 2.2's un-staged "examples of knowledge, skills +and attitudes" with two typed layers: `r cm(n$element_count_strict)` +coarse Competence Statements (one per competence times a four-level +proficiency band) and fine-grained Learning Outcomes, each typed +Knowledge, Skill, or Attitude. Applying the framework's own published +errata removes a duplicate outcome, leaving `r cm(n$example_count)`. +Competence areas and competences carry identical numbering and +membership to 2.2; a minority of area and competence names were +revised for 3.0. Proficiency-level descriptors and the glossary are staged from +the source but not emitted into the graph. ## Provenance @@ -111,7 +143,7 @@ materially understate what the publication actually contains. **Source** -DigComp 2.2 publication (Vuorikari, Kluzer, and Punie, Joint Research Centre, JRC128415). +DigComp 3.0 data supplement (Cosgrove and Cachia, Joint Research Centre, JRC144121, fifth edition). ::: ::: {.provenance-card} @@ -119,7 +151,7 @@ DigComp 2.2 publication (Vuorikari, Kluzer, and Punie, Joint Research Centre, JR **Ingestion** -`scripts/010-ingest-digcomp.R` parses the structure into the cybed schema. +`scripts/010-ingest-digcomp.R` verifies the staged supplement's hash, applies the framework's own published errata, and parses the structure into the cybed schema. ::: ::: {.provenance-card} @@ -127,15 +159,16 @@ DigComp 2.2 publication (Vuorikari, Kluzer, and Punie, Joint Research Centre, JR **License** -Creative Commons BY 4.0, by the source PDF's own imprint page, so reuse is allowed provided appropriate credit is given and any changes are indicated. The prescribed citation is: Vuorikari, R., Kluzer, S. and Punie, Y., DigComp 2.2: The Digital Competence Framework for Citizens, EUR 31006 EN, Publications Office of the European Union, Luxembourg, 2022, ISBN 978-92-76-48882-8, doi:10.2760/115376, JRC128415. The package does not bundle the source text. Users stage it locally. +Creative Commons BY 4.0, verified three ways: the dataset's own copyright notice, the JRC Data Catalogue record, and the DigComp 3.0 resources page. Reuse is allowed provided appropriate credit is given and any changes are indicated; the European Commission logo is excluded from reuse, and cybedtools reproduces no logo. The prescribed citation is: Cosgrove, J. and Cachia, R., DigComp 3.0: The Digital Competence Framework for Citizens, EUR 40491, Publications Office of the European Union, Luxembourg, 2025, ISBN 978-92-68-32677-0, doi:10.2760/0001149. Dataset: doi:10.2905/JRC.FR75K8R. The package does not bundle the source supplement. Users stage it locally. ::: ::: ## Caveats -- DigComp specifies **general digital competences for citizens, not cybersecurity-specific roles or curricula**. Competence Area 4 (Safety) carries the cybersecurity-relevant content, but the framework's specificity sits at general-digital-competence granularity, not at cybersecurity-workforce granularity. Treat DigComp as the citizen baseline that workforce frameworks like NICE and ECSF build above. -- The 2.2 Annex examples illustrating AI-and-digital-citizenship interactions are **not extracted into RDF triples**. Full DigComp 2.2 coverage requires consulting the publication's annex directly. +- DigComp specifies **general digital competences for citizens, not cybersecurity-specific roles or curricula**. Competence Area 4, renamed "Safety, wellbeing and responsible use" in 3.0, carries the most cybersecurity-adjacent content (protecting devices, protecting personal data and privacy), but the framework's specificity sits at general-digital-competence granularity, not at cybersecurity-workforce granularity. Treat DigComp as the citizen baseline that workforce frameworks like NICE and ECSF build above. +- Three of the framework's own published errata (LO4.3.22, LO5.2.12, LO5.4.13) name only a phrase to insert, with no replacement sentence quoted. Composing new sentence text from that instruction would put cybedtools's words in the steward's name, so those three are carried through unapplied; the other four corrections, including the one structural duplicate removal, are applied. +- Proficiency-level descriptors and the glossary are **staged but not emitted into the graph**. Full DigComp 3.0 coverage requires consulting the publication's own descriptors directly. ## Related diff --git a/concordance/frameworks/ecsf-v1.qmd b/concordance/frameworks/ecsf-v1.qmd index 5de5c6d..d971909 100644 --- a/concordance/frameworks/ecsf-v1.qmd +++ b/concordance/frameworks/ecsf-v1.qmd @@ -3,8 +3,7 @@ title: "ENISA ECSF v1" subtitle: "European Cybersecurity Skills Framework" jurisdiction: "EU" framework-type: "Workforce" -organizing-units: 12 -elements: 390 +framework-slug: "ecsf-v1" license: "ENISA re-use notice" date: 2026-05-08 categories: [workforce, eu] @@ -75,14 +74,37 @@ categories: [workforce, eu] ## Counts -| Measure | Value | -|---|---| -| Profiles | 12 | -| Strict elements | 374 | -| Subpoints (parsed enumeration lists) | 16 | -| With-examples elements | 390 | -| Elements per profile, strict | 31.2 | -| Elements per profile, with-examples | 32.5 | +```{r} +#| label: setup +#| include: false +n <- cybedtools::framework_summary +n <- as.list(n[n$framework_slug == "ecsf-v1", ]) +cm <- function(x) format(x, big.mark = ",") +d1 <- function(x) format(round(x, 1), nsmall = 1) +``` + +```{r} +#| label: tbl-counts +tibble::tibble( + Measure = c( + "Profiles", + "Strict elements", + "Subpoints (parsed enumeration lists)", + "With-examples elements", + "Elements per profile, strict", + "Elements per profile, with-examples" + ), + Value = c( + cm(n$organizing_unit_count), + cm(n$element_count_strict), + cm(n$subpoint_count), + cm(n$element_count_with_examples), + d1(n$elements_per_organizing_unit_strict), + d1(n$elements_per_organizing_unit_with_examples) + ) +) |> + knitr::kable() +``` ## Provenance diff --git a/concordance/frameworks/index.qmd b/concordance/frameworks/index.qmd index 2a89107..bed7d62 100644 --- a/concordance/frameworks/index.qmd +++ b/concordance/frameworks/index.qmd @@ -1,36 +1,79 @@ --- title: "Frameworks" subtitle: "What each framework specifies, where it sits, and what its source license permits" -listing: - # Every .qmd in this folder except this index. A "*-*.qmd" glob silently - # dropped any page whose filename carried no hyphen. - contents: ["*.qmd", "!index.qmd"] - type: table - fields: [title, jurisdiction, framework-type, organizing-units, elements, license] - field-display-names: - title: "Framework" - jurisdiction: "Jurisdiction" - framework-type: "Type" - organizing-units: "Units" - elements: "Elements" - license: "License" - sort: "framework-type asc, jurisdiction asc, title asc" - sort-ui: false - filter-ui: false - page-size: 10 - table-striped: true - table-hover: true --- ```{r} #| label: setup #| include: false +suppressPackageStartupMessages({ + library(dplyr) + library(reactable) +}) fs <- cybedtools::framework_summary n_fw <- nrow(fs) n_juris <- length(unique(fs$jurisdiction)) n_us <- sum(fs$jurisdiction == "US") n_ped <- sum(fs$framework_type == "pedagogy") ped_juris <- paste(sort(unique(fs$jurisdiction[fs$framework_type == "pedagogy"])), collapse = ", ") + +# The `Units`/`Elements` columns must come from framework_summary, computed +# at render time, never hand-typed per-page frontmatter -- a listing block +# reading YAML frontmatter cannot do that, so this table is built directly +# in R instead. `framework-slug` in each page's own frontmatter is the join +# key; the href map below is the one place the slug-to-filename +# correspondence has to be spelled out, since filenames don't all match +# their slug (`digcomp-30.qmd` for `digcomp-3.0`, `ccssf.qmd` for +# `ccssf-2022`, and so on). +page_of_slug <- c( + "ccssf-2022" = "ccssf.qmd", + "csec2017-v1" = "csec2017.qmd", + "csta-2017" = "csta-2017.qmd", + "csta-2026" = "csta-2026.qmd", + "cyberorg-k12-v1.0" = "cyberorg-k12.qmd", + "cybok-v1.1.0" = "cybok.qmd", + "cyqual-v1.2.0" = "cyqual.qmd", + "dcwf-v5.1" = "dcwf-v51.qmd", + "digcomp-3.0" = "digcomp-30.qmd", + "ecsf-v1" = "ecsf-v1.qmd", + "nice-v2" = "nice-v2.qmd", + "otccf-v1.1" = "otccf.qmd", + "scywf-1.5" = "scywf.qmd", + "sfia-9" = "sfia-9.qmd" +) + +fw_table <- fs |> + mutate( + Framework = framework_name, + href = sub("\\.qmd$", ".html", page_of_slug[framework_slug]), + Jurisdiction = jurisdiction, + Type = tools::toTitleCase(framework_type), + Units = organizing_unit_count, + Elements = element_count_with_examples, + License = license + ) |> + arrange(framework_type, jurisdiction, framework_name) |> + select(Framework, href, Jurisdiction, Type, Units, Elements, License) +``` + +```{r} +#| label: tbl-frameworks +reactable( + fw_table, + columns = list( + href = colDef(show = FALSE), + Framework = colDef(cell = function(value, index) { + htmltools::tags$a(href = fw_table$href[index], value) + }) + ), + sortable = TRUE, + filterable = FALSE, + striped = TRUE, + highlight = TRUE, + bordered = FALSE, + compact = TRUE, + defaultPageSize = 10 +) ``` The framework corpus splits along two axes: workforce versus diff --git a/concordance/frameworks/nice-v2.qmd b/concordance/frameworks/nice-v2.qmd index f32a16f..b6d69b4 100644 --- a/concordance/frameworks/nice-v2.qmd +++ b/concordance/frameworks/nice-v2.qmd @@ -3,8 +3,7 @@ title: "NICE Framework v2" subtitle: "Workforce Framework for Cybersecurity (NIST SP 800-181r1)" jurisdiction: "US" framework-type: "Workforce" -organizing-units: 53 -elements: 2225 +framework-slug: "nice-v2" license: "Public domain" date: 2026-05-08 date-modified: 2026-05-08 diff --git a/concordance/frameworks/otccf.qmd b/concordance/frameworks/otccf.qmd new file mode 100644 index 0000000..c7d5d0d --- /dev/null +++ b/concordance/frameworks/otccf.qmd @@ -0,0 +1,161 @@ +--- +title: "OTCCF v1.1" +subtitle: "Operational Technology Cybersecurity Competency Framework" +jurisdiction: "SG" +framework-type: "Workforce" +framework-slug: "otccf-v1.1" +license: "CSA copyright; non-commercial academic" +date: 2026-09-21 +categories: [workforce, sg] +--- + +## At a glance + +| | | +|---|---| +| Steward | Cyber Security Agency of Singapore (CSA), jointly developed with Mercer Singapore | +| Canonical page | [OTCCF publication](https://www.csa.gov.sg/resources/publications/operational-technology-cybersecurity-competency-framework--otccf-/) | +| Version | 1.1 (document self-describes as "version 1.1", effective 2021-10-08) | +| Source format | PDF | +| License | Copyright Cyber Security Agency of Singapore. CSA granted written permission to reference and integrate the OTCCF's structure for non-commercial, academic and research purposes | +| `framework_summary` slug | `otccf-v1.1` | + +## Structure in cybed: terms + +::: {.column-page} + +```{=html} +
+ + + OTCCF structural mapping + + + + + + + + + Framework + otccf:Framework + + + + + Job role + (15 job roles, plus 30 Technical Skill Competencies) + otccf:JobRole + = cybed:Role = cybed:OrganizingUnit + + + + + Key Task, Critical Core Skill, Knowledge, Ability + = cybed:RoleElement + + + + contains + cybed:hasOrganizingUnit + + + has element + cybed:hasElement + + +
Job roles carry Key Tasks through nested Critical Work Functions; Technical Skill Competencies carry per-level Knowledge and Ability statements. Click any box or edge label to flip between plain English and the technical schema name.
+
+``` + +::: + +## Counts + +```{r} +#| label: setup +#| include: false +n <- cybedtools::framework_summary +n <- as.list(n[n$framework_slug == "otccf-v1.1", ]) +u <- readRDS(here::here("_data", "unit_subtypes.rds"))$otccf +cm <- function(x) format(x, big.mark = ",") +d1 <- function(x) format(round(x, 1), nsmall = 1) +``` + +```{r} +#| label: tbl-counts +tibble::tibble( + Measure = c( + "Organizing units (job roles and TSC categories)", + "Job roles", + "Strict elements", + "Unit-relation edges (career-map and TSC cross-references)", + "Elements per organizing unit", + "Elements per job role" + ), + Value = c( + cm(n$organizing_unit_count), + cm(n$role_count), + cm(n$element_count_strict), + cm(n$unit_relation_count), + d1(n$elements_per_organizing_unit_strict), + d1(n$elements_per_role_strict) + ) +) |> + knitr::kable() +``` + +`r cm(n$role_count)` job roles sit under `r cm(u$track_count)` +career-map tracks, each carrying Key Tasks through nested Critical +Work Functions, Technical Skills & Competencies at a required +proficiency level, and Critical Core Skills at a Basic/Intermediate/ +Advanced level. `r cm(u$tsc_count)` Technical Skill Competencies +carry a six-level proficiency grid (Level 1 to Level 6) with per-level +Knowledge and Ability statements on every Technical Skill +Competency page. Level 1 is never populated for any skill; content +starts at Level 2, 3, or 4 depending on the skill. +`r cm(u$tsc_skillsfuture_count)` of the +`r cm(u$tsc_count)` are sourced from the SkillsFuture ICT Framework +rather than authored by CSA and are flagged as such in the ingested +data. + +## Provenance + +::: {.provenance-cards .column-page} + +::: {.provenance-card} + + +**Source** + +The Operational Technology Cybersecurity Competency Framework PDF, jointly developed by CSA and Mercer Singapore, supported by SkillsFuture Singapore and the Infocomm Media Development Authority. +::: + +::: {.provenance-card} + + +**Ingestion** + +`scripts/010-ingest-otccf.R` parses the Career Map, Skills Map, and TSC sections into the cybed schema, copying source text verbatim with no paraphrase, re-levelling, or summarisation, per the licence's fidelity condition. +::: + +::: {.provenance-card} + + +**License** + +Copyright Cyber Security Agency of Singapore. CSA granted written permission to reference and integrate the OTCCF's structure for non-commercial, academic and research use, on two conditions: a fixed attribution string, and that ingestion never misrepresent or alter the intent of CSA's published job roles, skills, or mappings. The grant covers structure only, not statement text. This site shows OTCCF job-role and TSC titles, counts, and mappings, and does not reproduce statement text. +::: + +::: + +## Caveats + +- The CSA grant is **structure only**. Cross-framework analyses that need OTCCF statement text must consult the source PDF directly; this site and the package's public outputs stop at identifiers, titles, and counts. +- Eight Technical Skill Competencies are **sourced from the SkillsFuture ICT Framework**, a body separate from CSA, and are flagged in the ingested data rather than folded silently into CSA-authored content. +- The document names Mercer Singapore as a **joint developer**; whether Mercer holds any interest the CSA grant does not reach is unresolved. + +## Related + +- [Cross-framework density spread](../queries/density-spread.qmd) +- [The vocabulary](../concepts/vocabulary.qmd) diff --git a/concordance/frameworks/scywf.qmd b/concordance/frameworks/scywf.qmd new file mode 100644 index 0000000..1e0f361 --- /dev/null +++ b/concordance/frameworks/scywf.qmd @@ -0,0 +1,163 @@ +--- +title: "SCyWF 1.5" +subtitle: "Saudi Cybersecurity Workforce Framework" +jurisdiction: "SA" +framework-type: "Workforce" +framework-slug: "scywf-1.5" +license: "NCA written permission" +date: 2026-09-21 +categories: [workforce, sa] +--- + +## At a glance + +| | | +|---|---| +| Steward | National Cybersecurity Authority (NCA), Kingdom of Saudi Arabia | +| Canonical page | [SCyWF official page](https://nca.gov.sa/en/pages/scywf.html) | +| Version | SCyWF – 1.5: 2026 | +| Source format | PDF | +| License | The document carries no licence notice of its own. NCA granted cybedtools written permission to ingest and redistribute its content, verbatim, on three conditions | +| `framework_summary` slug | `scywf-1.5` | + +## Structure in cybed: terms + +::: {.column-page} + +```{=html} +
+ + + SCyWF structural mapping + + + + + + + + + + Framework + scywf:Framework + + + + + Category, Specialty Area + (5 categories, 12 specialty areas) + = cybed:OrganizingUnit + + + + + Job role + scywf:JobRole + = cybed:Role = cybed:OrganizingUnit + + + + + Task, Knowledge, Skill statement + = cybed:RoleElement + + + + contains + cybed:hasOrganizingUnit + + + contains + cybed:hasOrganizingUnit + + + has element + cybed:hasElement + + +
Job roles carry a category and a specialty area (both derived hierarchy, not published edges) and are linked to Task, Knowledge and Skill statements by code. Click any box or edge label to flip between plain English and the technical schema name.
+
+``` + +::: + +## Counts + +```{r} +#| label: setup +#| include: false +n <- cybedtools::framework_summary +n <- as.list(n[n$framework_slug == "scywf-1.5", ]) +u <- readRDS(here::here("_data", "unit_subtypes.rds"))$scywf +cm <- function(x) format(x, big.mark = ",") +d1 <- function(x) format(round(x, 1), nsmall = 1) +``` + +```{r} +#| label: tbl-counts +tibble::tibble( + Measure = c( + "Organizing units (categories, specialty areas, job roles)", + "Job roles", + "Strict elements (Task, Knowledge, Skill statements)", + "Elements per organizing unit", + "Elements per job role" + ), + Value = c( + cm(n$organizing_unit_count), + cm(n$role_count), + cm(n$element_count_strict), + d1(n$elements_per_organizing_unit_strict), + d1(n$elements_per_role_strict) + ) +) |> + knitr::kable() +``` + +The document states its own size: `r cm(u$category_count)` +categories, `r cm(u$specialty_area_count)` specialty areas, and +`r cm(n$role_count)` job roles. Job-role cards in the source name only +Task, Knowledge, and Skill statement codes; the statement text itself +lives in separate appendix tables keyed by those codes, which this +ingestion joins back to each role. + +## Provenance + +::: {.provenance-cards .column-page} + +::: {.provenance-card} + + +**Source** + +The Saudi Cybersecurity Workforce Framework (SCyWF – 1.5: 2026) PDF, staged with a hash-verified provenance manifest. +::: + +::: {.provenance-card} + + +**Ingestion** + +`scripts/010-ingest-scywf.R` reconstructs table cells from PDF word coordinates and joins each job role's Task/Knowledge/Skill codes to their statement text in the appendix tables. +::: + +::: {.provenance-card} + + +**License** + +The document carries no copyright line or licence notice; its only markings, "TLP: Clear" and "Document Classification: Public," are disclosure markings rather than licences. NCA granted cybedtools written permission on 2026-09-20 to ingest and redistribute the content on three conditions: NCA cited as issuing body and source, everything taken from the document carried verbatim with no paraphrase or in-house translation, and anything cybedtools derives marked as derived rather than presented as NCA content. +::: + +::: + +## Caveats + +- The hierarchy edges between job role, specialty area, and category are **cybedtools's own derivation**, not a structure NCA published as edges. They are marked as derived per the terms of NCA's permission. +- NCA's grant named an **earlier edition** (SCyWF – 1: 2020) that NCA no longer publishes; the current 1.5: 2026 edition is ingested by owner decision under the same permission. +- The document's own disclaimer makes its **Arabic version binding** for matters of meaning and interpretation. cybedtools ingests the English version only. + +## Related + +- [Cross-framework density spread](../queries/density-spread.qmd) +- [The vocabulary](../concepts/vocabulary.qmd) diff --git a/concordance/frameworks/sfia-9.qmd b/concordance/frameworks/sfia-9.qmd index 00a613a..7320861 100644 --- a/concordance/frameworks/sfia-9.qmd +++ b/concordance/frameworks/sfia-9.qmd @@ -3,8 +3,7 @@ title: "SFIA 9" subtitle: "Skills Framework for the Information Age" jurisdiction: "Global" framework-type: "Workforce" -organizing-units: 147 -elements: 821 +framework-slug: "sfia-9" license: "SFIA license" date: 2026-05-08 categories: [workforce, global] @@ -76,18 +75,44 @@ categories: [workforce, global] ## Counts -| Measure | Value | -|---|---| -| Skills | 147 | -| Strict elements | 672 | -| Subpoints (parsed enumeration lists) | 149 | -| With-examples elements | 821 | -| Elements per skill, strict | 4.6 | -| Elements per skill, with-examples | 5.6 | +```{r} +#| label: setup +#| include: false +n <- cybedtools::framework_summary +n <- as.list(n[n$framework_slug == "sfia-9", ]) +u <- readRDS(here::here("_data", "unit_subtypes.rds"))$sfia +cm <- function(x) format(x, big.mark = ",") +d1 <- function(x) format(round(x, 1), nsmall = 1) +``` + +```{r} +#| label: tbl-counts +tibble::tibble( + Measure = c( + "Skills", + "Strict elements", + "Subpoints (parsed enumeration lists)", + "With-examples elements", + "Elements per skill, strict", + "Elements per skill, with-examples" + ), + Value = c( + cm(n$organizing_unit_count), + cm(n$element_count_strict), + cm(n$subpoint_count), + cm(n$element_count_with_examples), + d1(n$elements_per_organizing_unit_strict), + d1(n$elements_per_organizing_unit_with_examples) + ) +) |> + knitr::kable() +``` The elements counted here are level descriptors (one per skill-level -pairing where the skill is defined at that level). SFIA's seven -levels of responsibility define the scale itself. Those level +pairing where the skill is defined at that level). SFIA's +`r cm(u$level_count)`-level scale of responsibility (levels +`r min(u$levels)` through `r max(u$levels)` in the ingested data) +defines the scale itself. Those level definitions are part of the framework's overall structure but are not counted as per-skill elements. @@ -124,7 +149,7 @@ All use of SFIA is under license from the SFIA Foundation, and that license reac ## Caveats - SFIA enumerates **skills and proficiency levels, not roles**. Cross-framework analyses should not equate `sfia:Skill` with `nice:WorkRole` or `dcwf:WorkRole` without explicit justification. -- Level descriptors **only make sense relative to SFIA's seven-point level scale**. Cross-framework comparisons of "elements per organizing unit" reflect that scale, not absolute specificity. +- Level descriptors **only make sense relative to SFIA's `r cm(u$level_count)`-point level scale**. Cross-framework comparisons of "elements per organizing unit" reflect that scale, not absolute specificity. - The SFIA license **covers structure as well as text**. The SFIA Foundation's terms define the protected material as the concept, content, and structure of SFIA, and they name a skills database containing SFIA information as a royalty-bearing product. There is no structure-versus-text carve-out. Free tiers cover personal use and single-country corporate internal use. Redistribution and sub-licensing are prohibited at every tier. Anyone staging SFIA to run this pipeline needs a license of their own, and researchers extending cybedtools with downstream publications should verify their use case against the current terms. ## Related diff --git a/concordance/index.qmd b/concordance/index.qmd index 83a76ab..fc495da 100644 --- a/concordance/index.qmd +++ b/concordance/index.qmd @@ -22,9 +22,7 @@ fs <- cybedtools::framework_summary |> # Per-framework presentation facts the data does not carry: the page each # framework links to, and the short name used in the chart and the cards. -# The three v0.3.0 frameworks have no page of their own yet, so they link -# to their section of the package's data-sources documentation. -docs_url <- "https://github.com/ryanstraight/cybedtools/blob/main/docs/framework-data-sources.md" +# Every framework in framework_summary now has a page of its own. page <- c( "nice-v2" = "frameworks/nice-v2.html", "dcwf-v5.1" = "frameworks/dcwf-v51.html", @@ -32,11 +30,14 @@ page <- c( "sfia-9" = "frameworks/sfia-9.html", "cyberorg-k12-v1.0" = "frameworks/cyberorg-k12.html", "csta-2017" = "frameworks/csta-2017.html", + "csta-2026" = "frameworks/csta-2026.html", "csec2017-v1" = "frameworks/csec2017.html", - "digcomp-2.2" = "frameworks/digcomp-22.html", - "cyqual-v1.2.0" = paste0(docs_url, "#cyqual-czech-republic-national-cybersecurity-qualifications-framework"), - "ccssf-2022" = paste0(docs_url, "#ccssf-canada-canadian-cyber-security-skills-framework"), - "otccf-v1.1" = paste0(docs_url, "#otccf-singapore-operational-technology-cybersecurity-competency-framework") + "digcomp-3.0" = "frameworks/digcomp-30.html", + "cyqual-v1.2.0" = "frameworks/cyqual.html", + "ccssf-2022" = "frameworks/ccssf.html", + "otccf-v1.1" = "frameworks/otccf.html", + "scywf-1.5" = "frameworks/scywf.html", + "cybok-v1.1.0" = "frameworks/cybok.html" ) short <- c( "nice-v2" = "NICE Framework v2.2.0", @@ -45,20 +46,24 @@ short <- c( "sfia-9" = "SFIA 9", "cyberorg-k12-v1.0" = "Cyber.org K-12", "csta-2017" = "CSTA K-12 CS", + "csta-2026" = "CSTA PK-12 CS (2026)", "csec2017-v1" = "ACM/IEEE CSEC2017", - "digcomp-2.2" = "DigComp 2.2", + "digcomp-3.0" = "DigComp 3.0", "cyqual-v1.2.0" = "CyQUAL 1.2.0", "ccssf-2022" = "CCSSF 2022", - "otccf-v1.1" = "OTCCF v1.1" + "otccf-v1.1" = "OTCCF v1.1", + "scywf-1.5" = "SCyWF 1.5", + "cybok-v1.1.0" = "CyBOK v1.1.0" ) # Editorial flag, not a property of the data. It marks the one bar whose # strict-to-with-examples jump the chart annotates as an outlier. outlier <- c("csta-2017") juris_label <- c(US = "US", EU = "EU", global = "Global", - CZ = "CZ", CA = "CA", SG = "SG") + CZ = "CZ", CA = "CA", SG = "SG", SA = "SA", UK = "UK") juris_icon <- c(US = "icon-us", EU = "icon-eu", global = "icon-global", - CZ = "icon-cz", CA = "icon-ca", SG = "icon-sg") + CZ = "icon-cz", CA = "icon-ca", SG = "icon-sg", + SA = "icon-sa", UK = "icon-uk") fs <- fs |> mutate( diff --git a/concordance/queries/density-spread.qmd b/concordance/queries/density-spread.qmd index a794acf..f74ec1a 100644 --- a/concordance/queries/density-spread.qmd +++ b/concordance/queries/density-spread.qmd @@ -174,5 +174,5 @@ The `framework_summary` tibble ships with the package as a lazy-loaded data obje - [How is element coverage distributed across US and EU frameworks?](us-eu-asymmetry.qmd), the jurisdictional distribution and what it reframes. - [Which NICE work roles concentrate the most element coverage?](top-work-roles.qmd), power-law shape inside NICE. -- [How does the four-framework K-12 alignment compare?](k12-alignment.qmd), pedagogy lens. +- [How does the Cyber.org/CSTA K-12 alignment compare?](k12-alignment.qmd), pedagogy lens. - [The vocabulary](../concepts/vocabulary.qmd), why `cybed:OrganizingUnit` is the right denominator. diff --git a/concordance/queries/k12-alignment.qmd b/concordance/queries/k12-alignment.qmd index cf71cc6..6bd4fed 100644 --- a/concordance/queries/k12-alignment.qmd +++ b/concordance/queries/k12-alignment.qmd @@ -32,6 +32,9 @@ n_gained <- n_full_hit - n_parent_hit n_nomatch <- n_cyberorg - n_full_hit pct_nomatch <- round(n_nomatch / n_cyberorg * 100) n_moderate <- sum(alignment$full_strength %in% c("strong", "moderate")) +n_csta_subpoints <- cybedtools::framework_summary$subpoint_count[ + cybedtools::framework_summary$framework_slug == "csta-2017" +] ``` ## The question @@ -46,6 +49,8 @@ cybedtools v0.2.0 introduced the reclassification of both forms of clarification The parent-only-vs-full-document contrast is the finding. +This query stays on the 2017 CSTA edition rather than the 2026 edition added to the corpus since this page was written, because the two CSTA editions share no identifier scheme and re-anchoring the comparison is a separate piece of work. + ## The result ```{r} @@ -188,7 +193,7 @@ The parent pull is read straight out of the prep script, so this block cannot dr #| echo: true ``` -The `anti_join` is required, not optional. `cybed:Subpoint` children retain their parent's framework-native subtype (`csta:Standard`, `cyberorg:Standard`), so a plain type query catches parsed sub-points alongside genuine parents. CSTA carries 20 Subpoints this way. Cyber.org carries none, so its pool was clean by coincidence rather than by construction. +The `anti_join` is required, not optional. `cybed:Subpoint` children retain their parent's framework-native subtype (`csta:Standard`, `cyberorg:Standard`), so a plain type query catches parsed sub-points alongside genuine parents. CSTA carries `r n_csta_subpoints` Subpoints this way. Cyber.org carries none, so its pool was clean by coincidence rather than by construction. Element text is then pulled and scoped to the frameworks whose statement text may be published, via the site's publication guard: diff --git a/concordance/queries/nice-ecsf-alignment.qmd b/concordance/queries/nice-ecsf-alignment.qmd index 03e8b5e..8f65187 100644 --- a/concordance/queries/nice-ecsf-alignment.qmd +++ b/concordance/queries/nice-ecsf-alignment.qmd @@ -33,6 +33,28 @@ top_pair <- best |> arrange(desc(similarity)) |> slice_head(n = 1) med_sim <- sprintf("%.3f", median(best$similarity)) ciso_roles <- best |> filter(grepl("CISO", ecsf_name)) |> pull(nice_name) ecsf_top <- ecsf_counts |> slice_head(n = 1) +general_mgmt_roles <- intersect( + ciso_roles, + c("Technology Portfolio Management", "Secure Project Management") +) + +# Cross-reference against the NICE-to-CSEC2017 query's own cached result, +# not hand-typed, so the two pages cannot drift apart. +csec_best <- readRDS(here("_data", "nice_csec2017_best.rds")) +csec_med_sim <- sprintf("%.3f", median(csec_best$similarity)) +ecsf_vs_csec_ratio <- round(median(best$similarity) / median(csec_best$similarity)) + +# ECSF profiles that draw zero NICE roles as a top-1 match, and how many of +# those are recovered once rank 2-3 is included (from the saved top3 data), +# so "N of the five" is read off the data rather than hand-typed. +missed_profiles <- setdiff(unique(top3$ecsf_name), ecsf_counts$ecsf_name) +recovered_profiles <- top3 |> + filter(ecsf_name %in% missed_profiles) |> + distinct(ecsf_name) |> + pull(ecsf_name) +unrecovered_profiles <- setdiff(missed_profiles, recovered_profiles) +n_missed <- length(missed_profiles) +n_recovered <- length(recovered_profiles) ``` ## The question @@ -66,7 +88,7 @@ tibble::tibble( knitr::kable() ``` -Median best-match similarity is `r med_sim`, roughly three times the NICE-to-CSEC2017 figure (0.040 in [that query](nice-csec2017-alignment.qmd)). NICE and ECSF speak the same vocabulary world. The difference between them is jurisdiction and granularity, not framework purpose. +Median best-match similarity is `r med_sim`, roughly `r ecsf_vs_csec_ratio` times the NICE-to-CSEC2017 figure (`r csec_med_sim` in [that query](nice-csec2017-alignment.qmd)). NICE and ECSF speak the same vocabulary world. The difference between them is jurisdiction and granularity, not framework purpose. ```{r} #| label: fig-ecsf-distribution @@ -127,19 +149,19 @@ The strongest pair is NICE `r top_pair$nice_name` against ECSF `r top_pair$ecsf_ ### The vocabulary alignment is real -Median Jaccard `r med_sim` runs roughly three times the NICE-to-CSEC2017 result (0.040). The structural reason: both frameworks are workforce-position vocabularies. They are designed for the same kind of audience and use the same kind of language to describe the same kind of object, a cyber worker's role. +Median Jaccard `r med_sim` runs roughly `r ecsf_vs_csec_ratio` times the NICE-to-CSEC2017 result (`r csec_med_sim`). The structural reason: both frameworks are workforce-position vocabularies. They are designed for the same kind of audience and use the same kind of language to describe the same kind of object, a cyber worker's role. ### Granularity asymmetry drives the distribution ECSF specifies `r n_ecsf` broader role profiles. NICE specifies `r n_nice` detailed civilian roles. The many-to-one patterns concentrate around the broadest ECSF profiles (`r paste(sprintf("%s absorbs %d", ecsf_counts$ecsf_name[1:3], ecsf_counts$n[1:3]), collapse = ", ")`) and leave the narrower ECSF profiles (Implementer, Educator, Researcher, Risk Manager, Forensics Investigator) with zero top-1 matches. -### Three of the five unmatched ECSF profiles surface at rank 2 or 3 +### `r n_recovered` of the `r n_missed` unmatched ECSF profiles surface at rank 2 or 3 -They didn't surface as top-1 matches because other ECSF profiles absorbed their candidate NICE roles with marginally higher overlap. Expanding from rank-1 to rank-3 in the saved `top3` data recovers Cybersecurity Implementer, Cybersecurity Risk Manager, and Digital Forensics Investigator. It does not recover Cybersecurity Educator or Cybersecurity Researcher, neither of which appears in any NICE role's top three. For a policy briefing where coverage of all `r n_ecsf` ECSF profiles matters, those two need US-side commentary written by hand. +They didn't surface as top-1 matches because other ECSF profiles absorbed their candidate NICE roles with marginally higher overlap. Expanding from rank-1 to rank-3 in the saved `top3` data recovers `r paste(recovered_profiles, collapse = ", ")`. It does not recover `r paste(unrecovered_profiles, collapse = " or ")`, neither of which appears in any NICE role's top three. For a policy briefing where coverage of all `r n_ecsf` ECSF profiles matters, those need US-side commentary written by hand. ### CISO over-attracts management-flavored NICE roles -`r length(ciso_roles)` NICE roles best-match to it: `r paste(ciso_roles, collapse = ", ")`. Two of those, Technology Portfolio Management and Secure Project Management, read as general technology management rather than cybersecurity-specific leadership. CISO absorbs them by default because ECSF has no non-cybersecurity-specific management profile and CISO carries the broadest management vocabulary in the catalog. A policy reviewer should treat the CISO cluster as candidates for ECSF-side leadership-pathway alignment, not as ECSF CISO equivalents. +`r length(ciso_roles)` NICE roles best-match to it: `r paste(ciso_roles, collapse = ", ")`. `r length(general_mgmt_roles)` of those, `r paste(general_mgmt_roles, collapse = " and ")`, read as general technology management rather than cybersecurity-specific leadership. CISO absorbs them by default because ECSF has no non-cybersecurity-specific management profile and CISO carries the broadest management vocabulary in the catalog. A policy reviewer should treat the CISO cluster as candidates for ECSF-side leadership-pathway alignment, not as ECSF CISO equivalents. ## What this doesn't tell us @@ -193,7 +215,7 @@ top3 |> ) ``` -Rows group by NICE work role. Expand to see the top-3 ECSF profiles in similarity-descending order. Candidate equivalence pairs surface at the top of each role's group. Three of the ECSF profiles that drop out of the top-1 view (Cybersecurity Implementer, Cybersecurity Risk Manager, Digital Forensics Investigator) appear as rank-2 or rank-3 matches for several NICE roles. Cybersecurity Educator and Cybersecurity Researcher do not appear at any rank. +Rows group by NICE work role. Expand to see the top-3 ECSF profiles in similarity-descending order. Candidate equivalence pairs surface at the top of each role's group. `r n_recovered` of the ECSF profiles that drop out of the top-1 view (`r paste(recovered_profiles, collapse = ", ")`) appear as rank-2 or rank-3 matches for several NICE roles. `r paste(unrecovered_profiles, collapse = " and ")` do not appear at any rank. ## Reproduce this diff --git a/concordance/queries/us-eu-asymmetry.qmd b/concordance/queries/us-eu-asymmetry.qmd index f6caadc..46b9326 100644 --- a/concordance/queries/us-eu-asymmetry.qmd +++ b/concordance/queries/us-eu-asymmetry.qmd @@ -59,7 +59,7 @@ How does total element coverage in cybedtools split across the US and EU framewo Total element counts are an obvious cross-framework metric to reach for. They are also the easiest to misread. A reader who sees one jurisdiction's element count vastly exceeding another's may infer that the lagging jurisdiction is undercovered. The actual story is more often that the two jurisdictions chose different design philosophies for their workforce taxonomies. -The cybedtools corpus pulls `r n_us` US frameworks (NICE, DCWF, Cyber.org K-12, CSTA), `r n_eu` EU frameworks (ENISA ECSF, DigComp 2.2), `r n_global` global frameworks (SFIA, ACM/IEEE CSEC2017), and one each from the Czech Republic (CyQUAL), Canada (CCSSF), and Singapore (OTCCF). The element-count ratio between the US and EU rows is large and worth naming, but the framing matters more than the figure. +The cybedtools corpus pulls `r n_us` US frameworks (NICE, DCWF, Cyber.org K-12, CSTA), `r n_eu` EU frameworks (ENISA ECSF, DigComp 3.0), `r n_global` global frameworks (SFIA, ACM/IEEE CSEC2017), and one each from the Czech Republic (CyQUAL), Canada (CCSSF), and Singapore (OTCCF). The element-count ratio between the US and EU rows is large and worth naming, but the framing matters more than the figure. ## The result @@ -112,11 +112,11 @@ The `r n_us` US frameworks contribute `r cm(us_total)` elements. The `r n_eu` EU ### The ratio reflects design philosophy, not effort -NICE and DCWF were built to specify the US federal civilian and defense cyber workforces at hiring-pipeline granularity. The element count is the point. ECSF was authored as an EU-wide interoperability frame at profile granularity, deliberately light at the per-profile level. DigComp 2.2 specifies citizen-level digital competence, three nesting levels deep but deliberately compact. The frameworks were built to do different jobs. +NICE and DCWF were built to specify the US federal civilian and defense cyber workforces at hiring-pipeline granularity. The element count is the point. ECSF was authored as an EU-wide interoperability frame at profile granularity, deliberately light at the per-profile level. DigComp 3.0 specifies citizen-level digital competence, two organizing-unit tiers deep but deliberately compact per unit. The frameworks were built to do different jobs. -### Two materialization gaps sit on the EU side +### A materialization gap sits on the EU side -ECSF profiles point at the European e-Competence Framework (e-CF 4.0) at fine granularity, and those cross-references are not expanded into RDF triples in the cybedtools v0.3.0 graph. DigComp 2.2's Annex examples (the framework's signature 2022 update) are not extracted as queryable nodes either. Both gaps mean cybedtools' EU element count understates what those frameworks actually specify. +ECSF profiles point at the European e-Competence Framework (e-CF 4.0) at fine granularity, and those cross-references are not expanded into RDF triples in the cybedtools graph. DigComp 3.0's proficiency-level descriptors and glossary are staged from the source but likewise not emitted as queryable nodes. Both gaps mean cybedtools' EU element count understates what those frameworks actually specify. ### Reading the ratio as a coverage gap is wrong diff --git a/concordance/start/install.qmd b/concordance/start/install.qmd index a23b932..575b380 100644 --- a/concordance/start/install.qmd +++ b/concordance/start/install.qmd @@ -3,6 +3,14 @@ title: "Quick start" subtitle: "Install the package, stage framework data, run your first query" --- +```{r} +#| label: setup +#| include: false +fl <- cybedtools::framework_licenses +permission_slugs <- c("cyqual-v1.2.0", "ccssf-2022", "otccf-v1.1") +n_granted <- sum(fl$granted[fl$slug %in% permission_slugs]) +``` + ## Install cybedtools Install from GitHub via `remotes` or `pak`: @@ -36,10 +44,10 @@ copyright under 17 U.S.C. 105), the package ships pointers to the canonical NIST CPRT and DoD CIO downloads. For SFIA, ECSF, Cyber.org K-12, CSTA, CSEC2017, and DigComp, follow the steward's redistribution policy as documented per framework. CyQUAL, -CCSSF, and OTCCF are in the corpus on their stewards' terms, two by -written permission and CCSSF referenced as its steward asked, and -each carries its own terms. Read the per-framework entries -before staging those three. +CCSSF, and OTCCF are in the corpus on their stewards' terms, +`r n_granted` by written permission and CCSSF referenced as its +steward asked, and each carries its own terms. Read the per-framework +entries before staging those three. ## Run the pipeline @@ -60,7 +68,9 @@ Every query on this site reads from that file. ## First query -```{.r} +```{r} +#| label: first-query +#| echo: true library(cybedtools) library(dplyr) @@ -74,25 +84,6 @@ framework_summary |> arrange(desc(elements_per_organizing_unit_with_examples)) ``` -Output: - -``` -# A tibble: 11 × 3 - framework_name organizing_unit_count elements_per_organizing_unit_with_examples - - 1 DCWF v5.1 74 54.8 - 2 NICE v2.2.0 53 42 - 3 ECSF v1 12 32.5 - 4 OTCCF v1.1 61 26.4 - 5 CCSSF 2022 59 22.8 - 6 CyQUAL 1.2.0 161 20.7 - 7 CSTA K-12 CS (Rev 2017) 25 10.3 - 8 SFIA 9 147 5.6 - 9 ACM/IEEE CSEC2017 8 5 -10 Cyber.org K-12 v1.0 116 4.2 -11 DigComp 2.2 5 4.2 -``` - ## Next steps - Read [the concepts](../concepts/vocabulary.qmd) page for the diff --git a/concordance/use-cases/policy-analyst.qmd b/concordance/use-cases/policy-analyst.qmd index 9baa928..0029217 100644 --- a/concordance/use-cases/policy-analyst.qmd +++ b/concordance/use-cases/policy-analyst.qmd @@ -16,6 +16,14 @@ best <- readRDS(here("_data", "nice_ecsf_best.rds")) |> mutate(pct = round(similarity * 100, 1)) top3 <- readRDS(here("_data", "nice_ecsf_alignment.rds")) +# Cross-reference for the NICE-to-CSEC2017 comparison figure below, computed +# from the same cached best-match table the curriculum-coordinator page +# uses, not hand-typed, so the two pages cannot drift apart. +csec_best <- readRDS(here("_data", "nice_csec2017_best.rds")) |> + mutate(pct = round(similarity * 100, 1)) +csec_med_pct <- round(median(csec_best$pct)) +ecsf_vs_csec_ratio <- round(median(best$pct) / csec_med_pct) + fs <- cybedtools::framework_summary n_nice <- nrow(best) n_ecsf <- fs$organizing_unit_count[fs$framework_slug == "ecsf-v1"] @@ -95,7 +103,7 @@ best |> knitr::kable() ``` -Best-match overlap runs from `r min(best$pct)`% to `r max(best$pct)`% across all `r n_nice` NICE work roles, median `r median(best$pct)`%, with the strongest pair (NICE `r top_pair$nice_name` to ECSF `r top_pair$ecsf_name`) at `r top_pair$pct`%. The NICE-to-CSEC2017 median for comparison was 4%. The workforce-vocabulary alignment here runs roughly three times that, though the absolute overlap remains modest enough to support candidate-identification rather than equivalence claims. Both vocabularies describe workforce positions. The difference between them is jurisdiction and granularity, not framework purpose. +Best-match overlap runs from `r min(best$pct)`% to `r max(best$pct)`% across all `r n_nice` NICE work roles, median `r median(best$pct)`%, with the strongest pair (NICE `r top_pair$nice_name` to ECSF `r top_pair$ecsf_name`) at `r top_pair$pct`%. The NICE-to-CSEC2017 median for comparison was `r csec_med_pct`%. The workforce-vocabulary alignment here runs roughly `r ecsf_vs_csec_ratio` times that, though the absolute overlap remains modest enough to support candidate-identification rather than equivalence claims. Both vocabularies describe workforce positions. The difference between them is jurisdiction and granularity, not framework purpose. `r n_ecsf_hit` of ECSF's `r n_ecsf` role profiles appear as someone's top match. `r n_ecsf - n_ecsf_hit` never do (Cybersecurity Implementer, Cybersecurity Educator, Cybersecurity Researcher, Cybersecurity Risk Manager, Digital Forensics Investigator). The dominance of `r paste(sprintf("%s (%d NICE roles)", ecsf_counts$ecsf_name[1:3], ecsf_counts$n[1:3]), collapse = ", ")` reflects ECSF's deliberately-coarser role catalog. ECSF profiles cover broader role families that several NICE-position-specific roles fold into. diff --git a/concordance/why.qmd b/concordance/why.qmd index 68b5630..f6b38ec 100644 --- a/concordance/why.qmd +++ b/concordance/why.qmd @@ -12,7 +12,9 @@ n_licenses <- length(unique(fs$license)) n_role_fw <- sum(fs$framework_slug %in% c("nice-v2", "dcwf-v5.1", "ecsf-v1", "cyqual-v1.2.0", "ccssf-2022", "otccf-v1.1")) nice_per_role <- round(fs$elements_per_role_with_examples[fs$framework_slug == "nice-v2"]) -digcomp_per_unit <- round(fs$elements_per_organizing_unit_with_examples[fs$framework_slug == "digcomp-2.2"]) +digcomp_slug <- fs$framework_slug[grepl("^digcomp", fs$framework_slug)] +digcomp_name <- fs$framework_name[fs$framework_slug == digcomp_slug] +digcomp_per_unit <- round(fs$elements_per_organizing_unit_with_examples[fs$framework_slug == digcomp_slug]) juris <- fs |> dplyr::group_by(jurisdiction) |> dplyr::summarise(n = sum(element_count_with_examples), .groups = "drop") |> @@ -32,7 +34,7 @@ Defense's cyber workforce against NICE. ENISA's ECSF sketches twelve professional profiles for the European Union. SFIA catalogs skills across the global IT profession. Cyber.org and CSTA specify K-12 teaching standards. CSEC2017 frames undergraduate curricula. DigComp -2.2 specifies citizen-level digital competence. CyQUAL specifies the +specifies citizen-level digital competence. CyQUAL specifies the Czech Republic's national cybersecurity qualifications. CCSSF specifies Canada's cyber security skills for federal and adjacent employers. OTCCF specifies Singapore's operational-technology cybersecurity job @@ -61,11 +63,11 @@ Three findings ride out of that infrastructure cleanly. ### Per-unit element density spans roughly an order of magnitude -NICE specifies about `r nice_per_role` elements per work role. DigComp specifies about `r digcomp_per_unit` elements per competence area. The spread is real. It is a difference in design philosophy, not a quality claim. +NICE specifies about `r nice_per_role` elements per work role. `r digcomp_name` specifies about `r digcomp_per_unit` elements per organizing unit. The spread is real. It is a difference in design philosophy, not a quality claim. ### Element volume is unevenly distributed across `r nrow(juris)` jurisdictions -By with-examples element count, the totals run `r juris_text`. ECSF was designed to be profile-level by intent. DigComp was designed for citizen self-assessment. The distribution reflects authorial intent, not framework completeness. +By with-examples element count, the totals run `r juris_text`. ECSF was designed to be profile-level by intent. `r digcomp_name` was designed for citizen self-assessment. The distribution reflects authorial intent, not framework completeness. ### NICE work-role concentration is uneven diff --git a/context7.json b/context7.json index 7647c2e..3a053b1 100644 --- a/context7.json +++ b/context7.json @@ -3,7 +3,7 @@ "projectTitle": "cybedtools", "url": "https://context7.com/ryanstraight/cybedtools", "public_key": "pk_yg1eLuzggjzE1LDr0zMPi", - "description": "R package for cross-framework analysis of cybersecurity workforce and learning frameworks (NICE, DCWF, SFIA, ECSF, CyQUAL, CCSSF, OTCCF, Cyber.org K-12, CSTA, CSEC2017, DigComp 2.2) via a shared JSON-LD schema and SPARQL.", + "description": "Package for reproducible cross-framework analysis of cybersecurity workforce and learning frameworks (NICE, DCWF, SFIA, ECSF, CyQUAL, CCSSF, OTCCF, Cyber.org K-12, CSTA, CSEC2017, DigComp 3.0) via a shared JSON-LD schema and SPARQL.", "folders": [ "R", "man", diff --git a/data-raw/build-framework-licenses.R b/data-raw/build-framework-licenses.R index da69858..52b66f5 100644 --- a/data-raw/build-framework-licenses.R +++ b/data-raw/build-framework-licenses.R @@ -182,6 +182,28 @@ framework_licenses <- tibble::tribble( "https://creativecommons.org/licenses/by-nc-sa/4.0/", FALSE, as.Date("2026-09-18"), + "framework", "csta-2026", "CSTA PK-12 CS (2026)", + "CC BY-NC-SA 4.0", + paste0( + "From the 2026 CSTA PK-12 Computer Science Standards PDF's own licence ", + "page (p. iv): \"These Standards are licensed under the Creative Commons ", + "Attribution-NonCommercial-ShareAlike 4.0 International License. ", + "Accordingly, individuals and organizations are free to download, print, ", + "share, and adapt the materials in whole or in part, as long as they ", + "provide proper attribution, use for non-commercial purposes, and share ", + "contributions or derivations under the same license.\" Every page ", + "footer repeats \"CC BY-NC-SA 4.0\". cybedtools ingests the JSON behind ", + "CSTA's interactive Standards Explorer, which carries the same standard ", + "text and is published by CSTA, so it is treated under the same terms. ", + "The share-alike term propagates to any file containing CSTA-derived ", + "content. The attribution below is the PDF's own suggested citation and ", + "DOI, with the en dash in PK-12 rendered as a hyphen." + ), + "Computer Science Teachers Association. (2026). 2026 CSTA PK-12 computer science standards. https://csteachers.org/pk12standards/ DOI: https://doi.org/10.1145/3820482", + "full_with_attribution", + "https://creativecommons.org/licenses/by-nc-sa/4.0/", + FALSE, as.Date("2026-09-21"), + "framework", "csec2017-v1", "ACM/IEEE CSEC2017", "All rights reserved; educational use", paste0( @@ -200,24 +222,25 @@ framework_licenses <- tibble::tribble( "https://cybered.acm.org/", FALSE, as.Date("2026-09-18"), - "framework", "digcomp-2.2", "DigComp 2.2", + "framework", "digcomp-3.0", "DigComp 3.0", "CC BY 4.0", paste0( - "From the DigComp 2.2 report's own imprint page (EUR 31006 EN, ", - "JRC128415): \"(c) European Union, 2022 / The reuse policy of the ", - "European Commission is implemented by the Commission Decision ", - "2011/833/EU of 12 December 2011 on the reuse of Commission documents ", - "... Except otherwise noted, the reuse of this document is authorised ", - "under the Creative Commons Attribution 4.0 International (CC BY 4.0) ", - "licence ... This means that reuse is allowed provided appropriate ", - "credit is given and any changes are indicated.\" Nothing cybedtools ", - "ingests is marked \"otherwise noted\", and the notice's carve-out for ", - "photos and other non-EU-owned material does not reach it." + "From the copyright notice distributed with the official DigComp 3.0 ", + "data supplement (JRC144121, ISBN 978-92-68-32677-0): \"(c) European ", + "Union, 1995-2026 / The Commission's reuse policy is implemented by the ", + "Commission Decision of 12 December 2011 on the reuse of Commission ", + "documents. Any copyright and/or sui generis right on the dataset is ", + "licensed under the Creative Commons Attribution 4.0 International (CC ", + "BY 4.0) licence. Reuse is allowed provided appropriate credit is given ", + "and any changes are indicated.\" Also confirmed on the JRC Data ", + "Catalogue record (DOI 10.2905/JRC.FR75K8R) and the DigComp 3.0 ", + "resources page, which additionally excludes the European Commission ", + "logo from reuse; cybedtools reproduces no logo." ), - "Vuorikari, R., Kluzer, S. and Punie, Y., DigComp 2.2: The Digital Competence Framework for Citizens, EUR 31006 EN, Publications Office of the European Union, Luxembourg, 2022, ISBN 978-92-76-48882-8, doi:10.2760/115376, JRC128415.", + "Cosgrove, J. and Cachia, R., DigComp 3.0: The Digital Competence Framework for Citizens, EUR 40491, Publications Office of the European Union, Luxembourg, 2025, ISBN 978-92-68-32677-0, doi:10.2760/0001149. Dataset: doi:10.2905/JRC.FR75K8R.", "full_with_attribution", "https://creativecommons.org/licenses/by/4.0/", - FALSE, as.Date("2026-09-18"), + FALSE, as.Date("2026-09-21"), "framework", "cyqual-v1.2.0", "CyQUAL 1.2.0", "Open data, attribution required", @@ -233,36 +256,34 @@ framework_licenses <- tibble::tribble( "https://platform.cyqual.cz/en", TRUE, as.Date("2026-09-18"), - # CCSSF. The steward's written reply said only that the material is - # copyrighted under the Government of Canada and should be referenced when - # used. That is an instruction about referencing, not an affirmative grant, - # so `granted` is FALSE and no column in this row claims otherwise. Decided - # 2026-09-19: public text must not characterise the reply as more than it - # was until the steward confirms. Statement text stays unpublished. + # CCSSF. Owner decision 2026-09-21: the Canadian Centre for Cyber Security + # gave cybedtools written permission for full-text publication with + # attribution, superseding the 2026-09-19 decision that its earlier reply + # (an instruction to reference the material, not an affirmative grant) was + # not enough to publish statement text. `granted` is now TRUE and + # `public_redistribution` is `full_with_attribution`. "framework", "ccssf-2022", "CCSSF 2022", - "Government of Canada copyright", + "Government of Canada, with permission", paste0( "The Canadian Cyber Security Skills Framework (ITSM.00.039) carries no ", "licence notice of any kind: a sweep of the document finds no copyright ", "line, no Creative Commons statement, no Open Government Licence ", "reference and no all-rights-reserved statement. It carries only the ", "Government of Canada publication identifiers (ISBN 978-0-660-46231-8, ", - "Cat. No. D97-4/00-039-2022E-PDF). The Canadian Centre for Cyber ", - "Security states that its material is copyrighted under the Government ", - "of Canada and should be referenced when used. The document is marked ", - "\"TLP:CLEAR\" and \"UNCLASSIFIED / NON CLASSIFIE\" on every page; both ", - "are disclosure markings rather than copyright licences, so neither ", - "widens what may be republished. Do not assume the Open Government ", - "Licence applies." + "Cat. No. D97-4/00-039-2022E-PDF). The document is marked \"TLP:CLEAR\" ", + "and \"UNCLASSIFIED / NON CLASSIFIE\" on every page; both are disclosure ", + "markings rather than copyright licences. The Canadian Centre for Cyber ", + "Security gave cybedtools written permission on 2026-09-21 for full-text ", + "publication with attribution." ), paste0( "Canadian Centre for Cyber Security, The Canadian Cyber Security Skills ", "Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada. ", - "Referenced as the Canadian Centre for Cyber Security asked." + "Used with permission of the Canadian Centre for Cyber Security." ), - "structure_only", + "full_with_attribution", "https://www.cyber.gc.ca/en/publications", - FALSE, as.Date("2026-09-18"), + TRUE, as.Date("2026-09-21"), "framework", "otccf-v1.1", "OTCCF v1.1", "CSA copyright; non-commercial academic", @@ -284,7 +305,61 @@ framework_licenses <- tibble::tribble( "Derived from the Operational Technology Cybersecurity Competency Framework (OTCCF), published by the Cyber Security Agency of Singapore (CSA). Available at: https://www.csa.gov.sg/resources/publications/operational-technology-cybersecurity-competency-framework--otccf-/", "structure_only", "https://www.csa.gov.sg/resources/publications/operational-technology-cybersecurity-competency-framework--otccf-/", - TRUE, as.Date("2026-09-18") + TRUE, as.Date("2026-09-18"), + + # SCyWF. NCA's written grant of 2026-09-20 covers the full content with + # attribution, on three conditions recorded below. NCA prescribed no + # attribution wording, so the attribution is composed here from what the + # grant requires: NCA named as issuing body and source, with the official + # page link. + "framework", "scywf-1.5", "SCyWF 1.5", + "NCA written permission", + paste0( + "The Saudi Cybersecurity Workforce Framework (SCyWF – 1.5: 2026) PDF ", + "carries no copyright line and no licence notice. Its only markings are ", + "\"TLP: Clear\" and \"Document Classification: Public\", which are ", + "disclosure markings rather than licences. The National Cybersecurity ", + "Authority (NCA) of the Kingdom of Saudi Arabia granted cybedtools ", + "written permission on 2026-09-20 to ingest and redistribute the ", + "framework's content, on three conditions. (1) NCA is cited as issuing ", + "body and source, with a link to the official SCyWF page. (2) Everything ", + "taken from the document (identifiers, titles, statement text) is ", + "carried verbatim, with no normalised labels, paraphrase, in-house ", + "translation or composed text presented as NCA content. (3) Anything ", + "cybedtools derives, such as hierarchy edges or cross-framework ", + "mappings, is marked as derived and not presented as NCA content. NCA ", + "prescribed no ", + "attribution wording, so the attribution below is composed to meet the ", + "first condition. The grant named an earlier edition (SCyWF - 1 : 2020) ", + "that NCA no longer publishes. The current edition is ingested by owner ", + "decision. The document's own disclaimer makes its Arabic version ", + "binding for matters of meaning and interpretation. cybedtools ingests ", + "the English version." + ), + "The Saudi Cybersecurity Workforce Framework (SCyWF – 1.5: 2026). Issued by the National Cybersecurity Authority (NCA), Kingdom of Saudi Arabia. Source: https://nca.gov.sa/en/pages/scywf.html", + "full_with_attribution", + "https://nca.gov.sa/en/pages/scywf.html", + TRUE, as.Date("2026-09-21"), + + # CyBOK. An open licence, so no grant. The attribution is the wording the + # Introduction's copyright page prescribes, word for word, including its + # http link. terms_url is the https address of the same licence. + "framework", "cybok-v1.1.0", "CyBOK v1.1.0", + "OGL v3.0", + paste0( + "From the copyright page of the Introduction to CyBOK Knowledge Area, ", + "Version 1.1.0 (July 2021): \"© Crown Copyright, The National Cyber ", + "Security Centre 2021. This information is licensed under the Open ", + "Government Licence v3.0.\" The same page prescribes the attribution ", + "below. The A-to-Z Indicative Material document and each Knowledge Tree ", + "carry the same Crown Copyright and Open Government Licence v3.0 notice. ", + "The Open Government Licence v3.0 permits copying, ", + "publishing, adapting and commercial use on the condition of attribution." + ), + "CyBOK © Crown Copyright, The National Cyber Security Centre 2021, licensed under the Open Government Licence: http://www.nationalarchives.gov.uk/doc/open-government-licence/.", + "full_with_attribution", + "https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/", + FALSE, as.Date("2026-09-21") ) # Integrity checks. These duplicate the test suite deliberately: the build @@ -308,29 +383,49 @@ stopifnot( all(nchar(framework_licenses$license_short) < 40L), "every terms_url must be https" = all(grepl("^https://", framework_licenses$terms_url)), - "the CCSSF row must not claim permission" = - !any(grepl("permission", + # Owner decision 2026-09-21: the Centre's written permission for full-text + # publication with attribution replaced the earlier "must not claim + # permission" guard. The guard now points the other way: the row must say + # so, and must not still carry the pre-permission "referenced as ... asked" + # wording from before the grant. + "the CCSSF row must claim permission and be granted TRUE" = { + row <- framework_licenses[framework_licenses$slug == "ccssf-2022", ] + any(grepl("permission", unlist(row), ignore.case = TRUE)) && + isTRUE(row$granted) && + identical(row$public_redistribution, "full_with_attribution") + }, + "the CCSSF row must not carry the pre-permission wording" = + !any(grepl("referenced as.*asked", unlist(framework_licenses[framework_licenses$slug == "ccssf-2022", ]), ignore.case = TRUE)) ) # The framework rows must cover exactly the shipped framework_summary slugs. # Checked here as well as in the tests so a rebuild of either object cannot -# quietly drift from the other. +# quietly drift from the other, in the direction that can break a release. if (exists("framework_summary")) { fw_slugs <- framework_licenses$slug[framework_licenses$layer == "framework"] missing_license <- setdiff(framework_summary$framework_slug, fw_slugs) missing_summary <- setdiff(fw_slugs, framework_summary$framework_slug) - if (length(missing_license) > 0L || length(missing_summary) > 0L) { + # A licence row with no summary row yet is the expected state while a + # framework is being added: this object is built first, because the + # summary build derives its licence column from it and refuses a framework + # without a licence row. That direction is reported, not fatal. The summary + # build and the test suite both require the two to agree exactly after. + if (length(missing_license) > 0L) { stop( - "framework_licenses and framework_summary disagree about which ", - "frameworks exist.\n missing a licence row: ", - paste(missing_license, collapse = ", "), - "\n missing a summary row: ", - paste(missing_summary, collapse = ", "), + "framework_licenses has no row for shipped framework_summary ", + "framework(s): ", paste(missing_license, collapse = ", "), call. = FALSE ) } + if (length(missing_summary) > 0L) { + message( + "Licence row(s) with no framework_summary row yet: ", + paste(missing_summary, collapse = ", "), + ". Rebuild data-raw/build-framework-summary.R next." + ) + } } if (!dir.exists("data")) dir.create("data") diff --git a/data-raw/build-framework-summary.R b/data-raw/build-framework-summary.R index 544cf81..eecb565 100644 --- a/data-raw/build-framework-summary.R +++ b/data-raw/build-framework-summary.R @@ -168,10 +168,13 @@ display <- tibble::tribble( "https://w3id.org/cybed/ontology#framework/cyberorg-k12-v1.0", 5L, "Cyber.org K-12 v1.0", "pedagogy", "https://w3id.org/cybed/ontology#framework/csta-2017", 6L, "CSTA K-12 CS (Rev 2017)", "pedagogy", "https://w3id.org/cybed/ontology#framework/csec2017-v1", 7L, "ACM/IEEE CSEC2017", "pedagogy", - "https://w3id.org/cybed/ontology#framework/digcomp-2.2", 8L, "DigComp 2.2", "pedagogy", + "https://w3id.org/cybed/ontology#framework/digcomp-3.0", 8L, "DigComp 3.0", "pedagogy", "https://w3id.org/cybed/ontology#framework/cyqual-v1.2.0", 9L, "CyQUAL 1.2.0", "workforce", "https://w3id.org/cybed/ontology#framework/ccssf-2022", 10L, "CCSSF 2022", "workforce", - "https://w3id.org/cybed/ontology#framework/otccf-v1.1", 11L, "OTCCF v1.1", "workforce" + "https://w3id.org/cybed/ontology#framework/otccf-v1.1", 11L, "OTCCF v1.1", "workforce", + "https://w3id.org/cybed/ontology#framework/csta-2026", 12L, "CSTA PK-12 CS (2026)", "pedagogy", + "https://w3id.org/cybed/ontology#framework/scywf-1.5", 13L, "SCyWF 1.5", "workforce", + "https://w3id.org/cybed/ontology#framework/cybok-v1.1.0", 14L, "CyBOK v1.1.0", "pedagogy" ) # Licence labels, derived. framework_licenses is the single owner of licence @@ -290,7 +293,7 @@ role_dependent <- c("role_count", "elements_per_role_strict", non_role_cols <- setdiff(names(framework_summary), role_dependent) stopifnot( - "framework_summary should have 11 rows" = nrow(framework_summary) == 11L, + "framework_summary should have 14 rows" = nrow(framework_summary) == 14L, "no NA values expected outside the role-dependent columns" = !any(is.na(framework_summary[non_role_cols])), "the role-dependent columns must be NA together or present together" = @@ -353,11 +356,34 @@ if (!is.null(previous_summary)) { # widen this vector to a pattern, and do not remove the guard. guard_exempt <- "license" + # Acknowledged changes: one framework, named columns, a stated cause. Not + # an exemption, and never a pattern. Each entry lets exactly these cells + # move for exactly this framework and is printed below when it does. + # + # digcomp-3.0: commit 5a9bafe disabled the sub-point parser for DigComp + # 3.0 on fidelity grounds (its Competence Statement text split into + # fragments JRC did not publish) and updated the invariants and roxygen + # counts, but the shipped summary was not rebuilt with it. Its 97 + # Subpoints go to 0, which moves the two with-examples columns with them. + # Parents (362) and Examples (522) are unchanged. + acknowledged_drift <- list( + `digcomp-3.0` = c("subpoint_count", "element_count_with_examples", + "elements_per_organizing_unit_with_examples") + ) + drifted <- character(0) + acknowledged <- character(0) for (cl in setdiff(old_cols, guard_exempt)) { ov <- old_df[[cl]] nv <- new_df[[cl]] bad <- which(!mapply(identical, as.list(ov), as.list(nv))) + ack <- bad[vapply(old_slugs[bad], \(sl) cl %in% acknowledged_drift[[sl]], logical(1))] + if (length(ack) > 0L) { + acknowledged <- c(acknowledged, sprintf(" %s: %s was %s, now %s", cl, + old_slugs[ack], format(ov[ack]), + format(nv[ack]))) + } + bad <- setdiff(bad, ack) if (length(bad) > 0L) { drifted <- c(drifted, sprintf( " %s: %s", @@ -377,6 +403,13 @@ if (!is.null(previous_summary)) { call. = FALSE ) } + if (length(acknowledged) > 0L) { + cat("Acknowledged changes (see acknowledged_drift): +", + paste(acknowledged, collapse = " +"), " +", sep = "") + } cat("Regression guard passed: all pre-existing columns unchanged for the original eight frameworks", if (length(guard_exempt)) paste0(" (exempt: ", paste(guard_exempt, collapse = ", "), ")"), ".\n", sep = "") diff --git a/data/framework_licenses.rda b/data/framework_licenses.rda index 8cca2fb..e10e3b0 100644 Binary files a/data/framework_licenses.rda and b/data/framework_licenses.rda differ diff --git a/data/framework_summary.rda b/data/framework_summary.rda index 90d356d..3745bd7 100644 Binary files a/data/framework_summary.rda and b/data/framework_summary.rda differ diff --git a/docs/data-release.yml b/docs/data-release.yml index 461a277..a637f5c 100644 --- a/docs/data-release.yml +++ b/docs/data-release.yml @@ -20,12 +20,17 @@ # manifest and in the release README, so they are written for a reader outside # the project. -release_version: "2026.09.1" +# History: release 2026.09.1 (2026-09-19) shipped nice, dcwf, ecsf, digcomp, +# cyqual, cyberorg-k12, csta, otccf, and held ccssf, csec2017, csta-2026, +# scywf, cybok pending steward confirmation or because they joined the +# pipeline after that release was cut. That built output is preserved at +# data/processed/release/2026.09.1/ (gitignored) and is not revised. +release_version: "2026.09.2" # The release date is recorded here rather than stamped at build time. A # generation timestamp inside the manifest would change the manifest's bytes on # every run and destroy the reproducibility the hashes exist to demonstrate. -release_date: "2026-09-19" +release_date: "2026-09-21" graph_schema_version: "0.3.0" vocabulary_iri: "https://w3id.org/cybed/ontology#" @@ -61,7 +66,15 @@ long_literal_allow_predicates: - "http://schema.org/license" - "http://schema.org/creditText" -# Release 1. One gzip-compressed N-Triples file per slug, named by slug. +# Release 2. One gzip-compressed N-Triples file per slug, named by slug. +# Every framework whose policy in docs/framework-invariants.yml permits +# public redistribution now ships. csec2017 and otccf ship structure_only +# (the export's predicate-omission gate handles that automatically); every +# other slug here is full_with_attribution. ccssf moves from held to shipped +# because the Canadian Centre for Cyber Security's 2026-09-21 permission +# supersedes the 2026-09-19 reference-only reading. csta-2026, scywf, and +# cybok move from held to shipped because they joined the ingestion pipeline +# after 2026.09.1 was cut and are now ready. shipped: - nice - dcwf @@ -71,13 +84,17 @@ shipped: - cyberorg-k12 - csta - otccf + - ccssf + - csec2017 + - csta-2026 + - scywf + - cybok # Cleared by policy for a structure-only file, withheld from this release # anyway. A deposit cannot be withdrawn, so a framework ships only once its -# steward has confirmed in writing. -held: - ccssf: "Steward confirmation pending." - csec2017: "Steward confirmation pending." +# steward has confirmed in writing. Empty for 2026.09.2: every previously +# held framework is now confirmed or ready (see `shipped` above). +held: {} # Refused by policy, in every release. The export stops if a slug listed here # has any policy other than local_only, and stops if a local_only slug appears diff --git a/docs/framework-data-sources.md b/docs/framework-data-sources.md index 42ac2d5..5c9c968 100644 --- a/docs/framework-data-sources.md +++ b/docs/framework-data-sources.md @@ -26,9 +26,9 @@ Per-framework parser output in v0.2.0: | Cyber.org K-12 | enabled | 0 | 377 | Largest single-framework gain, routed to `cybed:Example`. Every parent has an explicit "Clarification statement:" segment with enumerated examples. | | CSTA K-12 CS | enabled | 20 | 114 | Two extraction paths: 20 Subpoints come from "such as" enumerations in the `standard` column (framework-as-specified); 114 Examples come from the separate `clarification` column (one Example per non-empty clarification, treated as pedagogical scaffolding). | | ACM/IEEE CSEC2017 | enabled | 2 | 0 | Connective filter handles source-truncated standards. Re-spot-check 0/2. The two Subpoints (least privilege, open design) are real. | -| DigComp 2.2 | enabled | 0 | 0 | Clean numbered standards; no enumerations to lift. | +| DigComp 2.2 (historical, replaced by 3.0) | enabled | 0 | 0 | Clean numbered standards; no enumerations to lift. | -The table above is the v0.2.0 measurement and is kept as a dated snapshot. The three frameworks added in v0.3.0 are not in it. Their current counts, from `framework_summary`, are CyQUAL 852 subpoints and 0 examples, CCSSF 197 subpoints and 0 examples, and OTCCF 0 of either, since its parser is off. Current per-framework counts for all eleven are in `framework_summary$subpoint_count` and `framework_summary$example_count`. +The table above is the v0.2.0 measurement and is kept as a dated snapshot. The three frameworks added in v0.3.0 are not in it. Their current counts, from `framework_summary`, are CyQUAL 852 subpoints and 0 examples, CCSSF 197 subpoints and 0 examples, and OTCCF 0 of either, since its parser is off. CSTA PK-12 CS (2026)'s parser is also off, on the same fidelity grounds as OTCCF (2026-09-21): 0 subpoints, and 652 examples, which are CSTA's published implementation examples rather than parser output. DigComp 3.0 replaced DigComp 2.2 in place (2026-09-21); its parser is off on the same fidelity grounds, and its Learning Outcomes are attached examples, not parser output. SCyWF's parser is off too, because NCA's permission requires verbatim carriage: 0 subpoints and 0 examples. CyBOK's parser is off as well. Its subpoints are the Indicative Material its Knowledge Trees draw under each Topic, not parser output. Current per-framework counts for all fourteen are in `framework_summary$subpoint_count` and `framework_summary$example_count`. The parser algorithm: @@ -41,7 +41,7 @@ The parser algorithm: **Known parser limitation.** The introducer-phrase set is exact: prose using less common framings such as "may include", "can include", "typically include", "for instance", or bullet-list enumerations under headings (e.g., SFIA's "Activities may include but are not limited to:" guidance-note pattern) is not extracted. A future revision of any framework that adopts one of these untracked patterns will silently produce zero Subpoints for that framework's affected elements. Verify with `parse_subpoints()` against representative source text before assuming exhaustive extraction. -Per-framework opt-out: set the environment variable `CYBED_DISABLE_SUBPOINT_PARSER` to a comma-separated list of slugs (e.g., `nice,cyberorg-k12`) before invoking `scripts/020-assemble-jsonld.R`. The default ingestion runs the parser against ten of the eleven frameworks. OTCCF is the exception: its `parser_enabled` flag in `docs/framework-invariants.yml` is `false`, because fragments split out of CSA's statements would be units CSA did not publish. +Per-framework opt-out: set the environment variable `CYBED_DISABLE_SUBPOINT_PARSER` to a comma-separated list of slugs (e.g., `nice,cyberorg-k12`) before invoking `scripts/020-assemble-jsonld.R`. The default ingestion runs the parser against eleven of the twelve frameworks. OTCCF is the exception: its `parser_enabled` flag in `docs/framework-invariants.yml` is `false`, because fragments split out of CSA's statements would be units CSA did not publish. ## Supported framework versions (cybedtools 0.3.0) @@ -53,11 +53,14 @@ Per-framework opt-out: set the environment variable `CYBED_DISABLE_SUBPOINT_PARS | SFIA | 9 | 2024-10 | SQLite (extract) | | Cyber.org K-12 | v1.0 | 2021-09-09 | PDF | | CSTA K-12 CS | Revised 2017 | 2017 | XLSX | +| CSTA PK-12 CS | 2026 | 2026-07 | JSON (Standards Explorer) + PDF | | ACM/IEEE CSEC | 2017 v1.0 | 2017-12-31 | PDF | -| DigComp | 2.2 | 2022-03-17 | PDF | +| DigComp | 3.0 | 2025-11-27 | JSON-LD + XLSX (data supplement) | | CyQUAL | 1.2.0 (open data export) | retrieved 2026-09-16 | JSON | | CCSSF | 2022 edition (ITSM.00.039) | 2023-04-19 | PDF | | OTCCF | v1.1 | 2021-10-08 | PDF | +| SCyWF | SCyWF – 1.5: 2026 | 2026 | PDF | +| CyBOK | v1.1.0 (KAs versioned separately) | 2021-07 | PDF | `NEWS.md` records which framework versions a given cybedtools release supports; this table tracks the current release. @@ -71,7 +74,7 @@ Each ingestion script targets a specific upstream schema. When a publisher relea If you need a newer upstream version that the current cybedtools release does not yet support, open an issue at with a sample of the new schema. Schema-revision PRs are welcome. -Active publisher revisions to watch (as of cybedtools 0.3.0): NICE Framework v2.2.0 components have shipped via NIST CPRT and are now ingested, CSA has said an OTCCF update is in progress, the Canadian Centre for Cyber Security has said an updated CCSSF edition is in preparation, CSTA has signaled a major revision in development (no published target date as of this writing; verify against CSTA's roadmap), SFIA 10 is in consultation, and DigComp 3.0 has been released. +Active publisher revisions to watch (as of cybedtools 0.3.0): NICE Framework v2.2.0 components have shipped via NIST CPRT and are now ingested, CSA has said an OTCCF update is in progress, the Canadian Centre for Cyber Security has said an updated CCSSF edition is in preparation, CSTA published its 2026 PK-12 standards, which are now ingested as a separate framework alongside the 2017 edition, SFIA 10 is in consultation, and DigComp 3.0 has been released and is now ingested, replacing 2.2 in place. ## Structural typing: cybed:OrganizingUnit and cybed:Role @@ -87,13 +90,16 @@ Per-framework structural typing: | SFIA | `sfia:Skill` | no | 147 skills at up to 7 responsibility levels | | Cyber.org K-12 | `cyberorg:StandardGroup` | no | 116 grade-band x sub-concept cells (4 grade bands x 29 sub-concepts; Cyber.org's documentation does not name the cell, so cybedtools labels it `cyberorg:StandardGroup` descriptively) | | CSTA K-12 CS | `csta:StandardGroup` | no | 25 level x concept cells (5 levels x 5 concepts; CSTA's published terminology uses level / concept / subconcept / practice but does not name the cell, so cybedtools labels it `csta:StandardGroup` descriptively) | +| CSTA PK-12 CS (2026) | `csta2026:StandardGroup` | no | 53 groups: 40 level x concept groups in the foundational tier (8 levels x 5 concepts) and 13 tier x specialty area groups in the specialty tier (6 areas at Specialty I and II, plus X+CS at Specialty I only) | | CSEC2017 | `csec:KnowledgeArea` | no | 8 Knowledge Areas (curricular thought-model groupings) | -| DigComp 2.2 | `digcomp:CompetenceArea` | no | 5 competence areas | +| DigComp 3.0 | `digcomp:CompetenceArea`, `digcomp:Competence` | no | 5 competence areas + 21 competences (26 units) | | CyQUAL 1.2.0 | `cyqual:WorkRole`, `cyqual:Competency` | yes (work roles) | 102 work roles plus 59 competencies, 161 organizing units in all | | CCSSF 2022 | `ccssf:WorkRole`, `ccssf:AdjacentRole` | yes | 22 core work roles and 37 cyber adjacent roles | | OTCCF v1.1 | `otccf:JobRole`, `otccf:TechnicalSkillCompetency` | yes (job roles) | 15 job roles plus the skill units they map to, 61 organizing units in all | +| SCyWF 1.5 | `scywf:JobRole`, `scywf:CompetencyArea`, `scywf:SpecialtyArea`, `scywf:Category` | yes (job roles) | 40 job roles plus 24 competency areas, 12 specialty areas and 5 categories, 81 organizing units in all | +| CyBOK v1.1.0 | `cybok:KnowledgeArea` | no | 21 Knowledge Areas, each carrying one of 5 categories as a literal | -Cross-framework SPARQL queries target `cybed:OrganizingUnit` to reach all eleven frameworks uniformly. Workforce-restricted queries target `cybed:Role` to reach only NICE / DCWF / ECSF / CyQUAL / CCSSF / OTCCF. Framework-specific queries target the per-framework subtype. The `framework_summary` tibble's `organizing_unit_count` column reports the cross-framework count (every framework's parents). Since v0.3.0 the tibble also carries `role_count`, which is the count of units typed `cybed:Role` and is `NA` for the five frameworks that assert no roles. +Cross-framework SPARQL queries target `cybed:OrganizingUnit` to reach all fourteen frameworks uniformly. Workforce-restricted queries target `cybed:Role` to reach only NICE / DCWF / ECSF / CyQUAL / CCSSF / OTCCF / SCyWF. Framework-specific queries target the per-framework subtype. The `framework_summary` tibble's `organizing_unit_count` column reports the cross-framework count (every framework's parents). Since v0.3.0 the tibble also carries `role_count`, which is the count of units typed `cybed:Role` and is `NA` for the seven frameworks that assert no roles. ## NICE (US, NIST) @@ -183,7 +189,21 @@ cybedtools therefore uses SFIA for local analysis only. It publishes no SFIA sta **Ingest.** `Rscript scripts/010-ingest-csta.R` -**Notes.** Single sheet, nine columns. 120 standards across five levels (1A, 1B, 2, 3A, 3B) and five concepts. Cybersecurity-relevant content concentrates in "Impacts of Computing" and "Networks & the Internet." 114 of the 120 standards carry pedagogical clarification text in a separate `clarification` column; the cybedtools assembler extracts each non-empty clarification as a `cybed:Example` node linked to the parent standard via `cybed:hasExample`. CSTA has signaled a major revision in development; verify against CSTA's published roadmap before assuming a target release window. +**Notes.** Single sheet, nine columns. 120 standards across five levels (1A, 1B, 2, 3A, 3B) and five concepts. Cybersecurity-relevant content concentrates in "Impacts of Computing" and "Networks & the Internet." 114 of the 120 standards carry pedagogical clarification text in a separate `clarification` column; the cybedtools assembler extracts each non-empty clarification as a `cybed:Example` node linked to the parent standard via `cybed:hasExample`. CSTA has since published the 2026 PK-12 standards, which cybedtools ingests as the separate framework `csta-2026` described below. The 2017 edition stays in the pipeline unchanged. + +## CSTA PK-12 CS 2026 (US, PK-12 computer science standards) + +**Source.** Computer Science Teachers Association, 2026 CSTA PK-12 Computer Science Standards (July 2026, DOI 10.1145/3820482, ISBN 979-8-4007-2780-1). The standards document is published at and the interactive Standards Explorer at . + +**License.** CC BY-NC-SA 4.0 (attribution, non-commercial, share-alike), read from the document's own licence page (PDF p. iv): "These Standards are licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License." Every page footer repeats the label. The Explorer JSON carries the same standard text and is published by CSTA, so it is treated under the same terms. + +**Attribution.** CSTA's suggested citation, from the same page: "Computer Science Teachers Association. (2026). 2026 CSTA PK-12 computer science standards. https://csteachers.org/pk12standards/" with DOI 10.1145/3820482. + +**Stage.** CSTA publishes no bulk CSV or XLSX for the 2026 edition. Stage the JSON behind the Standards Explorer as `data/raw/csta-2026/csta-2026-standards-api.json`, alongside the standards PDF and a `provenance.yml` that lists each staged file under `source.files` with its SHA256. The ingest never fetches anything. It stops unless every listed file is present with its recorded hash. + +**Ingest.** `Rscript scripts/010-ingest-csta2026.R` + +**Notes.** A separate framework from the 2017 edition, with its own slug (`csta-2026`), prefix (`csta2026:`) and identifier scheme. The two editions share no codes, and CSTA's official crosswalk between them is many-to-many, so it belongs to concordance work rather than to the ingest. 331 standards: 196 foundational across ten levels (EK, E1 to E5, MS, HS) and five concepts, and 135 specialty standards across Specialty I and II in seven specialty areas. CSTA's prose names six specialty areas and adds X+CS as interdisciplinary standards, while its identifier table and the Explorer treat X+CS as a seventh area code. X+CS is published at Specialty I only. Each standard is a `cybed:RoleElement` keyed by CSTA's code verbatim, grouped into 53 `csta2026:StandardGroup` units built from the (level, concept) pairs that occur. Each standard carries its tier, subconcept and, for the specialty tier, its specialty area code (27 standards carry `CYB`, the Cybersecurity area). Boundary statements, which set the limits of what a standard asks for, stay on the standard as `csta2026:boundaryStatement` literals. The 652 implementation examples are `cybed:Example` nodes reached through `cybed:hasExample`. The examples appear in the Explorer but not in the print PDF. Practices, dispositions, progressions and interdisciplinary connections are staged but not yet modelled. The first public data release was cut before this framework joined, so `docs/data-release.yml` holds it for the next release. ## ACM/IEEE CSEC2017 (global, higher-education cybersecurity curriculum) @@ -203,27 +223,39 @@ cybedtools therefore uses SFIA for local analysis only. It publishes no SFIA sta ## DigComp (EU, citizen digital competence) -**Source.** . The European Commission Joint Research Centre publishes DigComp via the JRC Publications Repository; navigate from the project page to JRC128415 for DigComp 2.2. +**Source.** . The European Commission Joint Research Centre publishes DigComp 3.0 as an official machine-readable data supplement (JSON-LD + XLSX, JRC144121), not a PDF: (dataset DOI 10.2905/JRC.FR75K8R; report DOI 10.2760/0001149). + +**License.** CC BY 4.0, verified three ways: the dataset's own `copyright.txt` ("© European Union, 1995-2026 ... Any copyright and/or sui generis right on the dataset is licensed under the Creative Commons Attribution 4.0 International (CC BY 4.0) licence ... Reuse is allowed provided appropriate credit is given and any changes are indicated."), the JRC Data Catalogue record, and the DigComp 3.0 resources page. The European Commission logo is excluded from reuse; cybedtools reproduces no logo. + +**Attribution.** Cosgrove, J. and Cachia, R., DigComp 3.0: The Digital Competence Framework for Citizens, EUR 40491, Publications Office of the European Union, Luxembourg, 2025, ISBN 978-92-68-32677-0, doi:10.2760/0001149. Dataset: doi:10.2905/JRC.FR75K8R. + +**Stage.** Save the data supplement under `data/raw/digcomp/v3.0/` (JSON-LD, XLSX, `copyright.txt`), hash-anchored in `data/raw/digcomp/v3.0/provenance.yml`. `data/raw/digcomp/v3.0/tables/` holds the verbatim `@graph` extraction the ingester reads. `data/raw/digcomp/v3.0/MAPPING-2.2-to-3.0.md` and `errata.csv` document the upgrade from 2.2. -**License.** CC BY 4.0, by the staged PDF's own imprint page: "© European Union, 2022 / The reuse policy of the European Commission is implemented by the Commission Decision 2011/833/EU of 12 December 2011 on the reuse of Commission documents (OJ L 330, 14.12.2011, p. 39). Except otherwise noted, the reuse of this document is authorised under the Creative Commons Attribution 4.0 International (CC BY 4.0) licence (https://creativecommons.org/licenses/by/4.0/). This means that reuse is allowed provided appropriate credit is given and any changes are indicated. For any use or reproduction of photos or other material that is not owned by the EU, permission must be sought directly from the copyright holders." Nothing cybedtools ingests is marked "otherwise noted" and none of it is photographic, so the third-party carve-out does not reach it. +**Ingest.** `Rscript scripts/010-ingest-digcomp.R`. Verifies the staged JSON-LD's SHA256 against the recorded hash, applies the 7 official errata to the learning outcomes (`errata.csv`; see the script's `apply_learning_outcome_errata()`), and writes the final tables to `data/raw/digcomp/tables/`. -**Attribution.** The prescribed string, verbatim from the imprint page: "How to cite this report: Vuorikari, R., Kluzer, S. and Punie, Y., DigComp 2.2: The Digital Competence Framework for Citizens, EUR 31006 EN, Publications Office of the European Union, Luxembourg, 2022, ISBN 978-92-76-48882-8, doi:10.2760/115376, JRC128415." +**Notes.** Replaces DigComp 2.2 in place (owner decision D1, 2026-08-21); 2.2's raw source stays archived at `data/raw/digcomp/v2.2/`. Complete extraction, no PDF-scrape limitations: 5 competence areas, 21 competences (identical numbering and area membership to 2.2), 362 Competence Statements, and 523 Learning Outcomes (522 post-errata). Proficiency levels (8-level descriptors with 4-level and CEFR-style 6-level crosswalks) and a 126-term glossary are staged but not emitted into the graph. -**Stage.** Save the PDF as `data/raw/digcomp/DigComp-2.2-JRC128415.pdf`. +**Cybersecurity scope caveat.** DigComp's specificity tag is "general-digital-competence" reflecting the framework's overall scope, but Area 4 ("Safety, wellbeing and responsible use") covers protecting devices, personal data and privacy, wellbeing, and environmental impacts, all of which overlap cybersecurity content. Researchers doing topic-level cross-framework comparisons should treat DigComp Area 4 competences and their Learning Outcomes as cybersecurity-relevant for those purposes, even though the framework as a whole is broader. -**Ingest.** `Rscript scripts/010-ingest-digcomp.R` +## CyBOK (UK, Cyber Security Body of Knowledge) -**Notes.** Best-effort PDF extraction via markitdown. 5 competence areas and 21 competences extract cleanly. The 8 proficiency levels per competence are documented in the source PDF but not automated (the source layout fragments the level descriptors across multi-column PDF layouts). DigComp 3.0 has been released and is not yet supported. +**Source.** The Cyber Security Body of Knowledge v1.1.0 (July 2021), from the CyBOK knowledge base page at . CyBOK publishes PDFs only, with no CSV, JSON or XML release. Three kinds of document are staged: the Introduction to CyBOK Knowledge Area (), the Knowledge Tree of each of the 21 Knowledge Areas, and the alphabetical Indicative Material index (). CyBOK versions each Knowledge Area separately, from 1.0.0 (Applied Cryptography, Formal Methods for Security) to 2.0.0 (Network Security). `data/raw/cybok/provenance.yml` records every file's URL, version and SHA256, and the URL of the full KA document each tree belongs to. -**Cybersecurity scope caveat.** DigComp's specificity tag is "general-digital-competence" reflecting the framework's overall scope, but Area 4 (Safety) covers protecting devices, personal data and privacy, health and well-being, and the environment, all of which overlap cybersecurity content. Researchers doing topic-level cross-framework comparisons should treat DigComp Area 4 competences as cybersecurity-relevant for those purposes, even though the framework as a whole is broader. +**License.** Open Government Licence v3.0. From the Introduction's copyright page: "© Crown Copyright, The National Cyber Security Centre 2021. This information is licensed under the Open Government Licence v3.0." The A-to-Z document and every Knowledge Tree carry the same notice. The licence permits copying, publishing, adapting and commercial use with attribution. It is an open licence, not a grant to cybedtools. -**Prose-density caveat.** DigComp 2.2's signature update over 2.1 is the addition of "21 new examples of knowledge, skills, and attitudes" per competence, distributed across Annexes 1-3 of the source PDF. The cybedtools sub-point parser does not currently surface these annex examples as graph elements (extraction fragments across multi-column layouts). DigComp's apparent low element density in `framework_summary` reflects this extraction limitation, not low pedagogical density in the framework itself. +**Attribution.** As the Introduction prescribes: CyBOK © Crown Copyright, The National Cyber Security Centre 2021, licensed under the Open Government Licence: http://www.nationalarchives.gov.uk/doc/open-government-licence/. + +**Stage.** Save the Introduction and the A-to-Z PDF under `data/raw/cybok/`, the 21 Knowledge Tree PDFs and the Introduction's own tree under `data/raw/cybok/trees/`, and a second extraction of each PDF made with xpdf `pdftotext -raw -enc UTF-8` under `data/raw/cybok/text/`, with a `provenance.yml` that lists each file under `source.files` with its SHA256. The ingest never fetches anything and stops unless every listed file matches. The two crosswalk CSVs staged earlier (CyBOK to SFIA, CyBOK to ECSF) stay in `data/raw/cybok/tables/`. + +**Ingest.** `Rscript scripts/010-ingest-cybok.R` + +**Notes.** CyBOK is a body of knowledge, not a workforce framework. It asserts no roles and no proficiency levels, so nothing in its graph asserts `cybed:Role`. Its 21 Knowledge Areas are `cybok:KnowledgeArea` organizing units, keyed by the acronyms the A-to-Z uses (RMG, AAA and so on), with their names as printed in Figure 2 of the Introduction. Each carries its category, one of the five of Figure 2, as the literal `cybok:category`, and its own document version as `schema:version`. CyBOK defines the two lower levels itself: the nodes directly under the root of a Knowledge Tree are Topics, and the nodes one layer further down are Indicative Material. Topics are `cybok:Topic` elements, numbered by cybedtools in tree order (`cybok:RMG-01`), because CyBOK prints no Topic codes. Indicative Material is `cybed:Subpoint` of its Topic, also typed `cybok:IndicativeMaterial`. Those Subpoints are nodes the tree draws, not parser output, and the parser is off. Which Topic a node hangs from is read from the tree drawing itself: the page is rendered, the node boxes are blanked out, and the remaining lines are split into connected components. Node position alone is not enough, because the Distributed Systems Security tree draws a Topic in the column where other Topics' children sit. The only text normalisation is whitespace and line-break hyphenation, and every carried string is looked up in the independent xpdf extraction. The A-to-Z index is used as a cross-check only. It files some terms under Topics worded differently from the trees, and lists some terms the current trees do not carry. `tables/a-to-z-resolution.csv` reports every row, and `tables/a-to-z.csv` is the A-to-Z table as printed. The Introduction's own tree (acronym CI in the A-to-Z) is parsed for that check and not ingested. The first public data release was cut before this framework joined, so `docs/data-release.yml` holds it for the next release. Two Applied Cryptography Indicative Material terms (AC-02.4, AC-02.5) are carried as the printed form the tree diagram shows rather than as either extraction actually produces them, because the tree's own PDF text layer and the independent xpdf verification extraction both drop the same spaces identically -- a PDF encoding fault in the source document, not an extraction bug. This is a closed, documented exception (owner decision 2026-09-21): see `data/raw/cybok/tables/verbatim-exceptions.csv` and the `corrections` note in `data/raw/cybok/provenance.yml`. ## Frameworks added by steward permission -Two of the three frameworks below are included with the written permission of their stewards, and the Canadian framework is referenced as its steward asked. Each has its own terms, stated under **License**, and those terms apply to you as well if you stage the source and build the graph. As with every framework here, cybedtools ships the ingestion code and not the source text. You retrieve the source yourself. +All four frameworks below are included with the written permission of their stewards. Each has its own terms, stated under **License**, and those terms apply to you as well if you stage the source and build the graph. As with every framework here, cybedtools ships the ingestion code and not the source text. You retrieve the source yourself. -Statement codes are unique only within a framework. CyQUAL keeps the task codes of the 2017 NICE Framework (its `T0516` is NICE's 2017 `T0516`), and it also uses the `T1xxx` range for its own additions, a range NIST later reused for different statements. A code that appears in two frameworks is not evidence that the statements match. Align frameworks through statement text or a published crosswalk, never by joining on a bare code. In the graph every identifier sits under its framework's own namespace, so graph queries cannot confuse them. The risk is in tables. +Statement codes are unique only within a framework. CyQUAL keeps the task codes of the 2017 NICE Framework (its `T0516` is NICE's 2017 `T0516`), and it also uses the `T1xxx` range for its own additions, a range NIST later reused for different statements. SCyWF prints codes of the same shape. It shares 296 codes with NICE v2.2.0 and prints different text under every one of them, while 762 of its statements are worded exactly as some NICE v2.2.0 statement under a different code. A code that appears in two frameworks is not evidence that the statements match. Align frameworks through statement text or a published crosswalk, never by joining on a bare code. In the graph every identifier sits under its framework's own namespace, so graph queries cannot confuse them. The risk is in tables. A separate hazard runs the other way, inside a single framework rather than across two. Some frameworks number their organizing units and their statements out of one id space, so a unit code and a statement code can be the same string. DCWF does this: its work-role codes and its task/KSA numbers share a numeric range. Cyber.org K-12 does it structurally, by naming a grade-band x sub-concept cell after the standard it holds. A framework prefix does not separate those, because both nodes are in the same framework. Unit IRIs in those two frameworks therefore carry a discriminator declared as `unit_iri_prefix` in `docs/framework-invariants.yml`: DCWF work role 462 is `dcwf:role-462` while statement 462 stays `dcwf:462`, and a Cyber.org cell is `cyberorg:cell-K-2.SEC.AUTH` while the standard stays `cyberorg:K-2.SEC.AUTH`. The unit's printed code is carried as a `schema:identifier` literal. `scripts/026-verify-graph.R` fails the build if any framework ever mints a fused IRI again. @@ -245,9 +277,9 @@ A separate hazard runs the other way, inside a single framework rather than acro **Source.** The Canadian Cyber Security Skills Framework (ITSM.00.039), Canadian Centre for Cyber Security. The public page carries the main document. The full document with Annexes A to E, which hold the role detail, is available from the Centre's Cyber Skills Development Team on request. -**License.** Copyright Government of Canada. The Centre supplied the full document for this use and asks that the material be referenced when used. The document itself carries no license notice, so do not assume the Open Government Licence applies. The document is marked "TLP:CLEAR" and "UNCLASSIFIED / NON CLASSIFIÉ" on every page. Both are disclosure markings under the Traffic Light Protocol and the Government of Canada's security classification scheme, and neither is a copyright licence, so they do not widen what the Centre granted. +**License.** Copyright Government of Canada. The Centre supplied the full document for this use and, on 2026-09-21, gave cybedtools written permission for full-text publication with attribution (owner decision, superseding the earlier reference-only reading of its reply). The document itself carries no license notice, so do not assume the Open Government Licence applies. The document is marked "TLP:CLEAR" and "UNCLASSIFIED / NON CLASSIFIÉ" on every page. Both are disclosure markings under the Traffic Light Protocol and the Government of Canada's security classification scheme, and neither is a copyright licence, so they do not widen what the Centre granted. -**Attribution.** Canadian Centre for Cyber Security, The Canadian Cyber Security Skills Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada. Referenced as the Canadian Centre for Cyber Security asked. +**Attribution.** Canadian Centre for Cyber Security, The Canadian Cyber Security Skills Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada. Used with permission of the Canadian Centre for Cyber Security. **Stage.** Save the PDF as `data/raw/ccssf/Canadian Cyber Security Skills Framework 2022 -ENG.pdf`. @@ -269,7 +301,20 @@ A separate hazard runs the other way, inside a single framework rather than acro **Notes.** The document describes itself as version 1.1, effective 8 October 2021. "V5" is the publisher's file label. The OTCCF has two independent halves, and cybedtools models both as published and does not fold one into the other. The 15 job roles are typed `cybed:Role` and hold their key tasks, each tagged with the Critical Work Function it sits under. The 30 Technical Skills and Competencies are typed `cybed:OrganizingUnit` and hold their own knowledge and ability statements by proficiency level. The two halves meet only in the skills maps, where CSA states which skills a role requires and at what level. Those statements are in the graph as `cybed:UnitRelation` nodes with the level exactly as printed (2 to 6 for technical skills, and Basic, Intermediate, or Advanced for Critical Core Skills). Eight of the 30 skills, the whole Organisational Management and Support category, are marked by CSA as extracted from the SkillsFuture ICT Framework, and the Critical Core Skills are defined by SkillsFuture Singapore. Both are tagged with `cybed:sourceCategory` so the second rights holder stays visible. The sub-point parser is off for this framework, because fragments split out of CSA's statements would be units CSA did not publish. The nesting of key tasks under Critical Work Functions is reconstructed from page layout. CSA has said an update is in progress. +## SCyWF (Saudi Arabia, Saudi Cybersecurity Workforce Framework) + +**Source.** National Cybersecurity Authority (NCA), Kingdom of Saudi Arabia, The Saudi Cybersecurity Workforce Framework, edition SCyWF – 1.5: 2026, English PDF, 128 pages, from the official SCyWF page at . The companion Career Progression document (SCyWF-CP 1:2026) is staged alongside it and not ingested yet. + +**License.** The document carries no copyright line and no licence notice, only the disclosure markings "TLP: Clear" and "Document Classification: Public". NCA granted cybedtools written permission (2026-09-20) to ingest and redistribute the framework's content with attribution, on three conditions. NCA is cited as issuing body and source, with a link to the official page. Everything taken from the document (identifiers, titles, statement text) is carried verbatim, with no normalised labels, paraphrase, in-house translation or composed text presented as NCA content. Anything cybedtools derives, such as hierarchy edges or cross-framework mappings, is marked as derived. The grant named an earlier edition (SCyWF - 1 : 2020) that NCA no longer publishes, and the current edition is ingested by owner decision. The document's own disclaimer makes its Arabic version binding for matters of meaning and interpretation. cybedtools ingests the English version. If you build on the SCyWF portion, the same conditions apply to you. + +**Attribution.** The Saudi Cybersecurity Workforce Framework (SCyWF – 1.5: 2026). Issued by the National Cybersecurity Authority (NCA), Kingdom of Saudi Arabia. Source: https://nca.gov.sa/en/pages/scywf.html + +**Stage.** Save the PDF as `data/raw/scywf/scywf_en.pdf`, and a second extraction of it made with xpdf `pdftotext -raw -enc UTF-8` as `scywf_en_raw.txt`, with a `provenance.yml` that lists each staged file under `source.files` with its SHA256. The ingest never fetches anything and stops unless every listed file matches. + +**Ingest.** `Rscript scripts/010-ingest-scywf.R` + +**Notes.** NICE-shaped: 40 job roles in 12 specialty areas and 5 categories, 646 tasks, 430 knowledge statements and 343 skill statements, and 24 competency areas. Role cards print Task, Knowledge and Skill codes only, so the 4,794 role-to-statement links come from the cards and the statement text from Appendix B. Every card code resolves to an Appendix B statement, and one statement (S1505) is on no card. Job roles are `scywf:JobRole` and `cybed:Role`, keyed by NCA's Job Role ID. Statements are keyed by NCA's printed codes, and skills carry NCA's "Technical?" column as `scywf:technical`. Competency areas, specialty areas and categories are organizing units that are not roles. Category ICSOT and specialty area ICSOT share one ID, so the group units carry an IRI discriminator (`scywf:category-ICSOT`, `scywf:specialty-ICSOT`) and keep the printed ID as `schema:identifier`. The edges `scywf:inSpecialtyArea` (role to specialty area) and `scywf:inCategory` (specialty area to category) are cybedtools-derived from the card fields and the role IDs. They are not NCA content. Extraction rebuilds table cells from word coordinates, and the only text normalisation is whitespace and line-break hyphenation, both documented in the ingest script. Every carried string (3,290 of them) is then looked up in the independent xpdf extraction, and the ingest stops on any miss. The sub-point parser is off. The category and specialty area descriptions of the document's Tables 1 and 2 are not carried yet. The first public data release was cut before this framework joined, so `docs/data-release.yml` holds it for the next release. + ## Frameworks not in this release - **e-CF (European e-Competence Framework, CWA 16234-1).** Not currently ingested. e-CF 2.0 RDF is freely available via [EU Joinup](https://joinup.ec.europa.eu/); e-CF 4.0 (the current version, 2020) is behind a CEN paywall unless an institutional license provides direct access. -- **CyBOK (Cybersecurity Body of Knowledge).** Not currently ingested. Published by the National Cyber Security Centre (UK) under CC BY-SA 4.0 and freely downloadable from . diff --git a/docs/framework-invariants.yml b/docs/framework-invariants.yml index 505bde2..ef94e44 100644 --- a/docs/framework-invariants.yml +++ b/docs/framework-invariants.yml @@ -108,8 +108,8 @@ frameworks: educational-development permission. digcomp: - version: "DigComp 2.2" - version_date: "2022-03-17" + version: "DigComp 3.0" + version_date: "2025-11-27" structural_type: "competence-framework" jurisdiction: "EU" sector: "citizen-education" @@ -117,14 +117,38 @@ frameworks: framework_type: "pedagogical" public_redistribution: full_with_attribution expected: - competence_areas: [5, 5] - competences: [21, 21] - descriptions_found: [18, 21] # allow 3-competence slip for body extraction + # Exact: the official JSON-LD data supplement, hash-anchored in + # data/raw/digcomp/v3.0/provenance.yml. Replaces DigComp 2.2 in place + # (owner decision D1, 2026-08-21). Skeleton unchanged from 2.2: 5 + # areas, 21 competences, identical numbering and area membership. + competence_areas: [5, 5] + competences: [21, 21] + descriptions_found: [21, 21] # official text, no extraction slip + competence_statements: [362, 362] + # 523 as published, 522 after applying errata E1 (duplicate + # LO2.5.09 removed). See errata.csv. + learning_outcomes: [522, 522] notes: | - Hand-curated 5 areas + 21 competences from TOC, body descriptions - extracted as first substantial paragraph per competence. Proficiency- - level descriptors (8 levels per competence) not automated. Cybersec - relevance concentrates in Safety area (4.1-4.4). + Source of record is the official DigComp 3.0 data supplement + (JSON-LD + XLSX, JRC144121), not a PDF scrape: every table is a + verbatim @graph extraction, no hand-curation, no truncation. + Replaces DigComp 2.2 (data/raw/digcomp/v2.2/ archived, not deleted). + Of the 7 official errata, only E1 to E4 are applied: E1 is + structural (delete + renumber) and E2 to E4 quote JRC's own + replacement text verbatim. Errata E5 to E7 (LO4.3.22, LO5.2.12, + LO5.4.13) are published as instructions without replacement text, + so the staged wording is carried unchanged rather than composing + new sentence text in JRC's name. The learning-outcome count (522) + comes from E1 alone and is unaffected by E5 to E7 being unapplied. + 4 of 5 area names and 13 of 21 competence names were revised; the + 21 competence IRIs (digcomp:COMP-x.y) and 5 area IRIs (digcomp:AREA-*) + survive unchanged. Competence Statements (362) and Learning Outcomes + (523, 522 post-errata) are new structure with no 2.2 counterpart -- + 2.2's ~250 "examples of knowledge, skills and attitudes" were never + staged. Proficiency levels (8-level descriptors with 4-level and + CEFR-style 6-level crosswalks) and a 126-term glossary are staged but + not emitted into the graph. Cybersec relevance concentrates in Area 4, + renamed "Safety, wellbeing and responsible use" (4.1-4.4). cyberorg-k12: version: "Cyber.org K-12 Cybersecurity Learning Standards v1.0" @@ -183,6 +207,57 @@ frameworks: Included for pedagogical breadth comparison against Cyber.org K-12. License: CC BY-NC-SA 4.0. + csta-2026: + version: "2026 CSTA PK-12 Computer Science Standards" + version_date: "2026-07" + structural_type: "learning-standards" + jurisdiction: "US" + sector: "K-12 education" + specificity: "general-computing" # cybersec coverage is a subset, not the whole + framework_type: "pedagogical" + # Non-commercial and share-alike conditions also apply; see framework_licenses. + public_redistribution: full_with_attribution + expected: + # Exact bounds: the source is a fixed JSON capture of CSTA's Standards + # Explorer whose 331 codes match the PDF one for one, and the ingest + # stops unless its SHA256 matches provenance.yml. Any drift means a + # different capture, which is a human-review event, not a tolerance to + # absorb. Measured 2026-09-21 from csta-2026-standards-api.json. + standards_count: [331, 331] + foundational_standards: [196, 196] + specialty_standards: [135, 135] + levels_count: [10, 10] # EK, E1-E5, MS, HS, S1, S2 + foundational_concepts: [5, 5] + specialty_areas: [7, 7] # six areas plus X+CS + cybersecurity_standards: [27, 27] # CYB: 15 Specialty I + 12 Specialty II + organizing_units: [53, 53] # observed pairs, never a crossing + foundational_units: [40, 40] # 8 levels x 5 concepts + specialty_units: [13, 13] # 6 areas x 2 tiers + X+CS at S1 only + boundary_statements: [660, 660] # paragraphs: 329 standards print 2, 2 print 1 + implementation_examples: [652, 652] + standards_with_examples: [326, 326] # unchanged: examples are attached from CSTA's example list, not parsed + ai_standards: [107, 107] + notes: | + A separate framework from csta-2017, not a revision of it. The two + editions share no identifier scheme; CSTA's official crosswalk between + them is many-to-many and is staged for concordance work, not ingested. + Source of record is the JSON behind CSTA's interactive Standards + Explorer, checked 1:1 against the 218-page PDF. Standards are + cybed:RoleElement nodes keyed by CSTA's code verbatim. Organizing units + are csta2026:StandardGroup: level x concept for the foundational tier, + tier level x specialty area for the specialty tier. X+CS is published + at Specialty I only, so it has one unit, not two. CSTA's prose names + six specialty areas and introduces X+CS in addition; its identifier + table and explorer treat X+CS as a seventh area code, which is how it is + carried here. + Implementation examples are web-display content that the print PDF + does not carry. They are cybed:Example nodes reached by + cybed:hasExample. Boundary statements are part of each standard and + are carried as csta2026:boundaryStatement literals, not as Examples. + Practices, dispositions, progressions and interdisciplinary connections + are staged but not yet modelled. + License: CC BY-NC-SA 4.0, read from the PDF's own licence page (p. iv). + ecsf: version: "ECSF v1" version_date: "2022-09-19" @@ -265,11 +340,11 @@ frameworks: jurisdiction: "CA" sector: "civilian" specificity: "cybersecurity-specific" - # Consent is implied, not affirmative, so statement text is withheld from - # publication: pending written confirmation from the Canadian Centre for - # Cyber Security; revisit when received. Structure (role titles, - # activity areas, competency titles, mappings) may be published. - public_redistribution: structure_only + # Owner decision 2026-09-21: the Canadian Centre for Cyber Security gave + # written permission for full-text publication with attribution, + # superseding the earlier structure-only withholding pending + # confirmation. + public_redistribution: full_with_attribution expected: # Exact bounds: the source is a fixed PDF edition whose annex structure # the ingester asserts against the document's own declared counts. Any @@ -330,11 +405,11 @@ frameworks: Text-length soft flags are expected on element-text and adjacent-comp: the source genuinely prints short runs ("None.", "VA tools", "BCP/DRP"). - Licensing: Copyright Government of Canada. Referenced as the Canadian - Centre for Cyber Security asked. Not granted permission; supplied - directly by the Cyber Skills Development Team, 2026-09-16, on - condition that ITSM.00.039 is referenced wherever the material is - used. + Licensing: Copyright Government of Canada. Used with permission of + the Canadian Centre for Cyber Security. Full-text publication with + attribution granted by the Cyber Skills Development Team, + 2026-09-16 (initial supply) and confirmed 2026-09-21, on condition + that ITSM.00.039 is referenced wherever the material is used. otccf: version: "OTCCF version 1.1" @@ -437,6 +512,113 @@ frameworks: mappings do not misrepresent or alter the intent of the framework as published. + scywf: + version: "SCyWF – 1.5: 2026" + version_date: "2026" + structural_type: "role-first" + jurisdiction: "SA" + sector: "civilian" + specificity: "cybersecurity-specific" + # Written permission from the National Cybersecurity Authority (NCA), + # 2026-09-20: full content with attribution, carried verbatim. See + # framework_licenses. + public_redistribution: full_with_attribution + expected: + # Exact bounds: the source is a fixed PDF (SHA256 in provenance.yml), + # parsed deterministically from word coordinates and checked string by + # string against an independent extraction. Any drift means a different + # edition or a parser change, which is a human-review event, not a + # tolerance to absorb. Measured 2026-09-21 from scywf_en.pdf. + categories: [5, 5] # stated by the document, p. 9 + specialty_areas: [12, 12] # stated by the document, p. 9 + job_roles: [40, 40] # stated by the document, p. 9 + tasks: [646, 646] # Table 9 + knowledge: [430, 430] # Table 10 + skills: [343, 343] # Table 11 + statements: [1419, 1419] + role_statement_links: [4794, 4794] # every code printed on a role card + unresolved_card_codes: [0, 0] # card codes with no Appendix B statement + orphan_statements: [1, 1] # S1505 is on no role card + competency_areas: [24, 24] # Table 12 + role_competency_area_links: [84, 84] + verbatim_failures: [0, 0] # carried strings missing from the check text + notes: | + The Saudi Cybersecurity Workforce Framework, issued by the National + Cybersecurity Authority of the Kingdom of Saudi Arabia. The ingested + edition is SCyWF – 1.5: 2026, by owner decision: NCA's grant named + an earlier edition (SCyWF - 1 : 2020) that is no longer published. + NICE-style: 40 job roles (scywf:JobRole, cybed:Role) keyed by NCA's + printed Job Role ID, and Task, Knowledge and Skill statements keyed by + NCA's printed codes. Role cards print codes only, so role-to-statement + links come from the cards and statement text from Appendix B. + SCyWF reuses NICE-shaped codes (T0036, K0001, S0001). A shared code + does not mean a shared statement: the text under a code shared with + NICE v2.2.0 differs every time. The two frameworks are never joined on + a bare code. + Categories and specialty areas are organizing units. Category ICSOT and + specialty area ICSOT share one ID, so both kinds carry an IRI + discriminator (category-, specialty-). Job role IDs, CA codes and + statement codes cannot collide, so no framework-wide unit_iri_prefix + is declared. The hierarchy edges (scywf:inSpecialtyArea, + scywf:inCategory) are cybedtools-derived, not NCA content. + The 24 competency areas of Appendix C are non-role organizing units + with no statement membership, which NCA does not publish. Roles reach + them through cybed:relatedUnit from the CA codes on their cards. + Sub-point parsing is disabled: NCA's permission requires verbatim + carriage, and a parser-split fragment would be text NCA did not print. + The Career Progression companion (SCyWF-CP 1:2026) is staged, not + ingested. The category and specialty area descriptions of Tables 1 + and 2 are not carried yet. + + cybok: + version: "CyBOK v1.1.0" + version_date: "2021-07" + structural_type: "body-of-knowledge" + jurisdiction: "UK" + sector: "general" + specificity: "cybersecurity-specific" + # Open Government Licence v3.0, Crown Copyright NCSC 2021, read from the + # Introduction's own copyright page. See framework_licenses. + public_redistribution: full_with_attribution + expected: + # Exact bounds: the sources are fixed PDFs (SHA256 in provenance.yml), + # read deterministically and checked string by string against an + # independent extraction. Any drift means a different release or a + # reader change, which is a human-review event, not a tolerance to + # absorb. Measured 2026-09-21. + categories: [5, 5] # Introduction, Figure 2 + knowledge_areas: [21, 21] # Introduction, Figure 2 + topics: [119, 119] # nodes on the root of the 21 Knowledge Trees + indicative_material: [477, 477] # nodes one layer below a Topic + topics_without_indicative_material: [2, 2] # AC-01 and AC-07, drawn as leaves + a_to_z_rows: [493, 493] # every printed row of the A-to-Z table + a_to_z_rows_resolved: [366, 366] # of the 482 rows naming a KA + verbatim_failures: [0, 0] # carried strings missing from the check text + crosswalk_ka_names_unresolved: [0, 0] # staged SFIA and ECSF crosswalks + notes: | + The Cyber Security Body of Knowledge v1.1.0 (July 2021), published by + the National Cyber Security Centre under the Open Government Licence + v3.0. A body of knowledge, not a workforce framework: no roles, no + proficiency levels, so nothing asserts cybed:Role. + 21 Knowledge Areas (cybok:KnowledgeArea, cybed:OrganizingUnit) keyed by + the acronyms of CyBOK's A-to-Z document, each carrying its category (one + of the 5 of the Introduction's Figure 2) as the literal cybok:category + and its own document version as schema:version, because CyBOK versions + each KA separately. Topics (cybok:Topic, cybed:RoleElement) and their + Indicative Material (cybed:Subpoint, also cybok:IndicativeMaterial) are + read from each KA's Knowledge Tree, the level-1 and level-2 nodes as + CyBOK itself defines them. Topic numbers (RMG-01) are cybedtools' own, + in tree order: CyBOK prints no Topic codes. + The tree links are read from the drawing, not from node position: + Distributed Systems Security draws the Topic "attacking P2P systems" + in the column where the other Topics' children sit. + The A-to-Z index disagrees with the trees in places (Topic wording, + terms the trees no longer carry). It is a cross-check only, reported + row by row in tables/a-to-z-resolution.csv, never a source of Topics. + The sub-point parser is off. Every Subpoint is a node the tree draws. + The Introduction's own Knowledge Tree is staged for the cross-check and + not ingested, as it is not a Knowledge Area. + # Graph invariants describe the post-expansion counts after the v0.1.1 # sub-point parser runs over each framework's elementText literals. These # differ from the per-framework source-data invariants above, which @@ -479,6 +661,10 @@ frameworks: # CCSSF joined 2026-09-18, adding 59 organizing units (all Role-typed: 22 core # work roles + 37 Annex E adjacent roles) and 1,353 elements (1,178 parents + # 175 Subpoints). +# CSTA PK-12 2026 joined 2026-09-21 as a twelfth framework, adding 53 non-role +# organizing units and 983 elements (331 standards + 652 implementation- +# example Examples). The sub-point parser is disabled for csta-2026 on +# fidelity grounds, the same treatment OTCCF gets. # OTCCF joined 2026-09-19, adding 61 organizing units (15 Role-typed job roles + # 30 non-role Technical Skills and Competencies + 16 non-role Critical Core # Skills) and 1,612 elements, all parents: the sub-point parser is disabled for @@ -487,20 +673,34 @@ frameworks: # CCSSF's 30 source-printed sub-bullets moved from the parent population to # cybed:Subpoint in the same pass, which is why its parent count drops by 30 # and its strict total by 8. +# SCyWF joined 2026-09-21 as a thirteenth framework, adding 81 organizing units +# (40 Role-typed job roles + 24 competency areas + 12 specialty areas + 5 +# categories) and 1,419 elements, all parents: the sub-point parser is +# disabled for scywf, as for OTCCF, csta-2026 and digcomp. +# CyBOK v1.1.0 joined 2026-09-21 as a fourteenth framework, adding 21 non-role +# organizing units (Knowledge Areas) and 596 elements (119 Topic parents + 477 +# Indicative Material Subpoints). Its Subpoints are source-drawn tree nodes, +# as CCSSF's source-printed sub-bullets are, and the parser is disabled. +# DigComp 3.0 joined 2026-09-21, replacing digcomp-2.2 in place, adding 26 +# organizing units (5 areas + 21 competences) and 884 elements (362 +# Competence Statement parents + 522 Learning Outcome Examples). Sub-point +# parser disabled for digcomp on fidelity grounds, the same treatment +# OTCCF and csta-2026 get. graph_invariants: combined: - organizing_unit_count: [719, 723] # all 11 frameworks' parents (+61 from OTCCF) - role_count: [304, 304] # NICE 42 + DCWF 74 + ECSF 12 + CyQUAL 102 + CCSSF 59 + OTCCF 15 - total_elements_with_subpoints: [14096, 14131] # parents + Subpoints across all 11 - total_elements_full: [14582, 14617] # adds Examples (Cyber.org K-12 ~369 + CSTA ~114) - example_count: [478, 492] # ~369 Cyber.org K-12 + ~114 CSTA + organizing_unit_count: [895, 899] # all 14 frameworks' parents (+53 from CSTA 2026, +21 from DigComp 3.0's new Competence tier, +81 from SCyWF, +21 from CyBOK) + role_count: [344, 344] # NICE 42 + DCWF 74 + ECSF 12 + CyQUAL 102 + CCSSF 59 + OTCCF 15 + SCyWF 40 + total_elements_with_subpoints: [16704, 16912] # parents + Subpoints across all 14 (+331 from CSTA 2026, +362 from DigComp 3.0 and +1,419 from SCyWF, all parser-disabled, and +596 from CyBOK's source-drawn tree) + total_elements_full: [18378, 18586] # adds Examples (Cyber.org K-12 ~369 + CSTA 2017 ~114 + CSTA 2026 652 + DigComp 3.0 522) + example_count: [1649, 1672] # ~369 Cyber.org K-12 + ~114 CSTA 2017 + 652 CSTA 2026 + 522 DigComp 3.0 # cybed:UnitRelation nodes. OTCCF is the only framework publishing # qualified unit-to-unit statements so far. unit_relation_count: [310, 310] # OTCCF 190 TSC + 120 distinct core-skill statements - framework_count: [11, 11] + framework_count: [14, 14] per_framework: - # Ten of the eleven frameworks run with the parser enabled; OTCCF runs - # with it off (see its parser_enabled: false below). The 2026-05-08 + # Nine of the fourteen frameworks run with the parser enabled. OTCCF, + # csta-2026, digcomp-3.0, scywf-1.5 and cybok-v1.1.0 run with it off (see their + # parser_enabled: false below). The 2026-05-08 # parser refinement (sentence-boundary stop + connective filter) # brought SFIA and CSEC2017 from initial 30%+ false-positive rates to # 0%. The v0.2.0 vocabulary additionally splits parsed content into @@ -564,6 +764,20 @@ graph_invariants: total_elements_full: [246, 262] strict_inflation: [1.10, 1.25] parser_enabled: true + csta-2026: + # Exact: a fixed source. 331 standards. Sub-point parser disabled for + # csta-2026, as for OTCCF: CSTA's standards are published statements, + # and a parser-split fragment would be a unit the package invented + # rather than one CSTA printed. The 652 Examples are CSTA's + # implementation examples, attached from the example list rather than + # parsed from text, so they are unaffected. + parent_elements: [331, 331] + subpoint_elements: [0, 0] + example_elements: [652, 652] + total_elements_with_subpoints: [331, 331] + total_elements_full: [983, 983] + strict_inflation: [1.00, 1.00] + parser_enabled: false csec2017-v1: parent_elements: [35, 42] subpoint_elements: [2, 4] @@ -610,11 +824,41 @@ graph_invariants: total_elements_full: [1612, 1612] strict_inflation: [1.00, 1.00] parser_enabled: false - digcomp-2.2: - parent_elements: [18, 21] + digcomp-3.0: + # Replaces digcomp-2.2 (2026-09-21). Parents are exact: the 362 + # Competence Statements from the official data supplement. Sub-point + # parser disabled 2026-09-21, as for OTCCF and csta-2026: the parser + # split Competence Statement text into fragments like "clickbait", + # "validating", and "AI systems" rather than framework-as-specified + # enumerations, an invented unit rather than one JRC printed. The + # 522 Examples are the (post-errata) Learning Outcomes, attached + # from the outcome table rather than parsed from statement text, so + # they are unaffected. + parent_elements: [362, 362] + subpoint_elements: [0, 0] + example_elements: [522, 522] + total_elements_with_subpoints: [362, 362] + total_elements_full: [884, 884] + strict_inflation: [1.00, 1.00] + parser_enabled: false + scywf-1.5: + # Exact: a fixed source. Parser disabled, so parents are the whole + # element population: 646 tasks + 430 knowledge + 343 skills. + parent_elements: [1419, 1419] subpoint_elements: [0, 0] example_elements: [0, 0] - total_elements_with_subpoints: [18, 21] - total_elements_full: [18, 21] - strict_inflation: [0.99, 1.01] - parser_enabled: true + total_elements_with_subpoints: [1419, 1419] + total_elements_full: [1419, 1419] + strict_inflation: [1.00, 1.00] + parser_enabled: false + cybok-v1.1.0: + # Exact: fixed sources. Parents are the 119 Topics. The 477 Subpoints + # are the Indicative Material the Knowledge Trees draw under them, not + # parser output: the parser is disabled. + parent_elements: [119, 119] + subpoint_elements: [477, 477] + example_elements: [0, 0] + total_elements_with_subpoints: [596, 596] + total_elements_full: [596, 596] + strict_inflation: [5.00, 5.01] + parser_enabled: false diff --git a/docs/ingestion-summary.md b/docs/ingestion-summary.md index d70dc9f..e98b9fa 100644 --- a/docs/ingestion-summary.md +++ b/docs/ingestion-summary.md @@ -1,15 +1,15 @@ --- title: Ingestion Summary type: status -date: 2026-09-18 +date: 2026-09-21 status: auto-generated --- # Ingestion Summary -Regenerate with `Rscript scripts/016-summarize-ingestion.R`. Last rendered 2026-09-18. +Regenerate with `Rscript scripts/016-summarize-ingestion.R`. Last rendered 2026-09-21. -**Frameworks staged: 15** +**Frameworks staged: 16** ## ASD Cyber Skills Framework (`asd`) @@ -60,8 +60,8 @@ Regenerate with `Rscript scripts/016-summarize-ingestion.R`. Last rendered 2026- - **spot check:** seed=2,026, elements_checked=15, exact_matches=15, discrepancies=0 ### Licensing -- **source license:** Copyright Government of Canada. Referenced as the Canadian Centre for Cyber Security asked. Supplied directly by the Cyber Skills Development Team, 2026-09-16. -- **redistribution:** Not granted permission; use with attribution. Reference the publication (ITSM.00.039) wherever the material or a derivative of it is used. +- **source license:** Copyright Government of Canada. Used with permission of the Canadian Centre for Cyber Security. Supplied directly by the Cyber Skills Development Team, 2026-09-16. +- **redistribution:** Full-text publication with attribution (owner decision 2026-09-21, written permission received from the Canadian Centre for Cyber Security). Reference the publication (ITSM.00.039) wherever the material or a derivative of it is used. ## CSEC2017 (`csec2017`) @@ -137,6 +137,13 @@ Regenerate with `Rscript scripts/016-summarize-ingestion.R`. Last rendered 2026- - **practices:** 12 - **practice categories:** 4 - **dispositions:** 7 +- **organizing units:** 53 +- **foundational units:** 40 +- **specialty units:** 13 +- **boundary statements:** 660 +- **implementation examples:** 652 +- **standards with examples:** 326 +- **ai standards:** 107 ### Licensing - **source license:** CC BY-NC-SA 4.0 @@ -173,23 +180,40 @@ Regenerate with `Rscript scripts/016-summarize-ingestion.R`. Last rendered 2026- ## CyBOK (`cybok`) -- **Publisher:** NA -- **Version:** CyBOK crosswalk (Tier A: cross-reference data only, not full CyBOK ingestion) -- **Version date:** 2021-10-01 +- **Publisher:** CyBOK project, University of Bristol, for the National Cyber Security Centre +- **Version:** CyBOK v1.1.0 +- **Version date:** 2021-07 ### Source -- **Type:** NA +- **Type:** official_pdf ### Retrieval -- **Retrieved:** 2026-08-14 by `manual extraction, staged for scripts/020-assemble-jsonld.R` +- **Retrieved:** 2026-09-21 by `manual staging (curl from www.cybok.org, URLs read from the CyBOK v1.1 knowledge base page)` ### Extracted scales - **cybok sfia mapping:** row_count=104, knowledge_area_count=21, distinct_sfia_codes=56 - **cybok ecsf mapping:** row_count=12, roles_with_primary_ka=8, roles_with_no_primary_ka=4 +- **categories:** 5 +- **knowledge areas:** 21 +- **topics:** 119 +- **indicative material:** 477 +- **topics without indicative material:** 2 +- **a to z rows:** 493 +- **a to z rows knowledge areas:** 482 +- **a to z rows resolved:** 366 +- **a to z status:** resolved=366, term under a different topic in the tree=1, topic and term not in tree=12, topic resolved, term not in tree=52, topic worded differently, term in tree=51 +- **a to z rows introduction:** 11 +- **a to z rows introduction resolved:** 7 +- **verbatim checked:** 622 +- **verbatim found:** 622 +- **crosswalk ka names resolved:** 27 +- **crosswalk ka names unresolved:** 0 ### Licensing -- **source license:** Open Government Licence (Crown Copyright / NCSC 2025) for the CyBOK-ECSF compatibility study; SFIA Foundation site terms for the CyBOK-SFIA mapping table. Verify before any source-text redistribution. -- **redistribution:** Analytical derivatives (the two crosswalk CSVs staged here) treated as safe; full CyBOK Knowledge Area text is out of scope for this Tier A ingest. +- **source license:** Open Government Licence v3.0. The Introduction (p. 1) and the A-to-Z document both read: "© Crown Copyright, The National Cyber Security Centre 2021. This information is licensed under the Open Government Licence v3.0." +- **attribution:** CyBOK © Crown Copyright, The National Cyber Security Centre 2021, licensed under the Open Government Licence: http://www.nationalarchives.gov.uk/doc/open-government-licence/. +- **terms url:** http://www.nationalarchives.gov.uk/doc/open-government-licence/ +- **status:** full_with_attribution ## CyQUAL (`cyqual`) @@ -246,26 +270,34 @@ Regenerate with `Rscript scripts/016-summarize-ingestion.R`. Last rendered 2026- ## DigComp (`digcomp`) - **Publisher:** European Commission Joint Research Centre (JRC) -- **Version:** DigComp 2.2 -- **Version date:** 2022-03-17 +- **Version:** DigComp 3.0 +- **Version date:** 2025-11-27 ### Source -- **Type:** pdf_with_markdown_intermediate -- **File:** `DigComp-2.2-JRC128415.pdf` +- **Type:** structured_data_supplement ### Retrieval -- **Retrieved:** 2026-05-08 by `scripts/010-ingest-digcomp.R` -- **SHA256:** `9733ab7946f094be78c00f6a839e26be5486a00d6f953d93ddf42a5c135ab359` +- **Retrieved:** 2026-09-21 by `scripts/010-ingest-digcomp.R` ### Extracted scales - **competence areas:** 5 - **competences:** 21 -- **descriptions found:** 20 +- **competence statements:** 362 +- **learning outcomes pre errata:** 523 +- **learning outcomes post errata:** 522 +- **proficiency levels staged:** 8 +- **glossary terms staged:** 126 + +### Errata +- **status:** PARTIALLY_APPLIED +- **applied:** E1, E2, E3, E4 +- **unapplied:** E5, E6, E7 +- **note:** E1 (structural: delete LO2.5.09, renumber 2.5's outcomes down by one) and E2/E3/E4 (exact quoted replacement text) applied verbatim. Errata E5 to E7 are published as instructions without replacement text, so the staged wording is carried unchanged. The learning-outcome count (522) is unaffected: it comes from E1 alone. ### Licensing -- **source license:** CC BY 4.0 (European Union, 2022; Commission Decision 2011/833/EU) -- **citation:** Vuorikari, R., Kluzer, S. and Punie, Y., DigComp 2.2: The Digital Competence Framework for Citizens, EUR 31006 EN, Publications Office of the European Union, Luxembourg, 2022, ISBN 978-92-76-48882-8, doi:10.2760/115376, JRC128415. -- **redistribution note:** CC BY 4.0 by the source PDF's own imprint page: reuse is allowed provided appropriate credit is given and any changes are indicated. The 'except otherwise noted' qualifier and the photo carve-out do not reach anything this script ingests. Use the prescribed citation above. +- **source license:** CC BY 4.0 (European Union, 2025; Commission Decision 2011/833/EU) +- **citation:** Cosgrove, J. and Cachia, R., DigComp 3.0: The Digital Competence Framework for Citizens, EUR 40491, Publications Office of the European Union, Luxembourg, 2025, ISBN 978-92-68-32677-0, doi:10.2760/0001149. Dataset: doi:10.2905/JRC.FR75K8R. +- **redistribution note:** CC BY 4.0, verified three ways in data/raw/digcomp/v3.0/provenance.yml: the dataset's own copyright.txt, the JRC Data Catalogue record, and the DigComp 3.0 resources page. The European Commission logo is excluded from reuse; cybedtools reproduces no logo. ## ECSF (`ecsf`) @@ -319,6 +351,10 @@ Regenerate with `Rscript scripts/016-summarize-ingestion.R`. Last rendered 2026- - **competency area ks associations count:** 412 - **role opm mappings count:** 40 +### Errata +- **applied:** 1 +- **note:** Source-data corrections applied deterministically at ingest. See errata.csv for element ids, published vs corrected values, and rationale. + ### Licensing - **source license:** US public domain (17 U.S.C. 105); foreign rights reserved but granted royalty-free worldwide, incl. derivative works; attribute NIST as source - **redistribution:** NICE text is a US Government work in the US public domain (17 U.S.C. 105). NIST reserves foreign rights and then grants them back: 'foreign rights are reserved. To the extent NIST may assert rights outside of the United States, the public is granted the non-exclusive, perpetual, paid-up, royalty-free, worldwide right to reprint works in all formats including print, electronically, and online, and in all subsequent editions, and derivative works.' Attribute NIST as the source and do not imply NIST endorsement. @@ -363,6 +399,43 @@ Regenerate with `Rscript scripts/016-summarize-ingestion.R`. Last rendered 2026- - **terms of use url:** https://www.csa.gov.sg/terms-of-use/ - **landing page copyright verbatim:** © 2026 Government of Singapore +## SCyWF (`scywf`) + +- **Publisher:** National Cybersecurity Authority (NCA), Kingdom of Saudi Arabia +- **Version:** SCyWF – 1.5: 2026 +- **Version date:** 2026 + +### Source +- **Type:** official_pdf + +### Retrieval +- **Retrieved:** 2026-09-21 by `manual staging (curl from cdn.nca.gov.sa, URLs located via the NCA English SCyWF landing page)` + +### Extracted scales +- **categories:** 5 +- **specialty areas:** 12 +- **job roles:** 40 +- **tasks:** 646 +- **knowledge:** 430 +- **skills:** 343 +- **statements:** 1,419 +- **role statement links:** 4,794 +- **unresolved card codes:** 0 +- **competency areas:** 24 +- **role competency area links:** 84 +- **hyphenation joins:** 0 +- **verbatim checked:** 3,290 +- **verbatim found:** 3,290 +- **nice shared codes:** 296 +- **nice identical text:** 0 +- **nice same text any code:** 762 + +### Licensing +- **source license:** Used with written permission of the National Cybersecurity Authority (NCA), Kingdom of Saudi Arabia, granted 2026-09-20. The document itself carries no copyright line and no licence notice, only the markings "TLP: Clear" and "Document Classification: Public", which are disclosure markings, not licences. Its p.3 disclaimer makes the Arabic version binding for all matters of meaning or interpretation. cybedtools ingests the English version. +- **attribution:** The Saudi Cybersecurity Workforce Framework (SCyWF – 1.5: 2026). Issued by the National Cybersecurity Authority (NCA), Kingdom of Saudi Arabia. Source: https://nca.gov.sa/en/pages/scywf.html +- **status:** full_with_attribution +- **permission granted:** 2026-09-20 + ## SFIA (`sfia`) - **Publisher:** NA @@ -417,18 +490,19 @@ Regenerate with `Rscript scripts/016-summarize-ingestion.R`. Last rendered 2026- | Framework | Version | Date | Top-level units | Elements | License | |---|---|---|---|---|---| | ASD Cyber Skills Framework | v2 (October 2022, originally v1.0 Jul... | 2022-10-01 | - | - | MIXED, confirmed by direct page-by-pa... | -| CCSSF | 2022 | 2023-04-19 | 59 | 1,222 | Copyright Government of Canada. Refer... | +| CCSSF | 2022 | 2023-04-19 | 59 | 1,222 | Copyright Government of Canada. Used ... | | CSEC2017 | CSEC2017 Curricular Guidelines v1.0 | 2017-12-31 | 8 | 38 | Copyright 2017 ACM/IEEE/AIS/IFIP, all... | | CSTA K-12 CS | CSTA K-12 Computer Science Standards ... | 2017 | 25 | 120 | CC BY-NC-SA 4.0 | -| CSTA PK-12 CS | 2026 CSTA PK-12 Computer Science Stan... | 2026-07 | - | - | CC BY-NC-SA 4.0 | +| CSTA PK-12 CS | 2026 CSTA PK-12 Computer Science Stan... | 2026-07 | 53 | 331 | CC BY-NC-SA 4.0 | | Cyber.org K-12 | Cyber.org K-12 Learning Standards v1.0 | 2021-09-09 | 116 | 123 | CC BY-NC 4.0 | -| CyBOK | CyBOK crosswalk (Tier A: cross-refere... | 2021-10-01 | - | - | Open Government Licence (Crown Copyri... | +| CyBOK | CyBOK v1.1.0 | 2021-07 | 21 | 596 | Open Government Licence v3.0. The Int... | | CyQUAL | 1.2.0 | NA | 102 | 2,488 | Published as open data by CyQUAL (Mas... | | DCWF | DCWF v5.1 | 2025-07-25 | 74 | 2,945 | US Government work, not subject to US... | -| DigComp | DigComp 2.2 | 2022-03-17 | 5 | 21 | CC BY 4.0 (European Union, 2022; Comm... | +| DigComp | DigComp 3.0 | 2025-11-27 | 5 | 21 | CC BY 4.0 (European Union, 2025; Comm... | | ECSF | ECSF v1 | 2022-09-19 | 12 | 374 | Report PDF: CC BY 4.0 (ENISA, 2022, I... | | NICE | NICE v2.2.0 (NIST SP 800-181 Rev 1 co... | 2026-04-28 | 42 | 2,211 | US public domain (17 U.S.C. 105); for... | | OTCCF | 1.1 | 2021-10-08 | 15 | 1,612 | Copyright Cyber Security Agency of Si... | +| SCyWF | SCyWF – 1.5: 2026 | 2026 | 40 | 1,419 | Used with written permission of the N... | | SFIA | SFIA 9 | 2024-10 | 147 | 672 | SFIA Foundation licence required for ... | | UKCSC | live site content (framework is unver... | NA | - | - | ALL RIGHTS RESERVED -- NOT openly lic... | diff --git a/inst/CITATION b/inst/CITATION index 787c853..4844916 100644 --- a/inst/CITATION +++ b/inst/CITATION @@ -2,7 +2,7 @@ citHeader("To cite cybedtools in publications, use:") bibentry( bibtype = "Manual", - title = "{cybedtools}: An R Package for Reproducible Analysis of Cybersecurity Workforce and Learning Frameworks", + title = "{cybedtools}: A Package for Reproducible Analysis of Cybersecurity Workforce and Learning Frameworks", author = c( person("Ryan", "Straight", email = "ryanstraight@arizona.edu", @@ -10,13 +10,13 @@ bibentry( comment = c(ORCID = "0000-0002-6251-5662")) ), year = "2026", - note = "R package version 0.3.1", + note = "R package version 0.4.0", # Zenodo concept DOI (resolves to latest version). doi = "10.5281/zenodo.20076116", url = "https://doi.org/10.5281/zenodo.20076116", textVersion = paste( - "Straight, R. (2026). cybedtools: An R Package for Reproducible Analysis", + "Straight, R. (2026). cybedtools: A Package for Reproducible Analysis", "of Cybersecurity Workforce and Learning Frameworks. R package version", - "0.3.1. https://doi.org/10.5281/zenodo.20076116" + "0.4.0. https://doi.org/10.5281/zenodo.20076116" ) ) diff --git a/inst/conformance/README.md b/inst/conformance/README.md new file mode 100644 index 0000000..0636313 --- /dev/null +++ b/inst/conformance/README.md @@ -0,0 +1,172 @@ +# cybedtools conformance fixtures + +This directory is the parity contract a Python port is built against. It is +committed to the repository and shipped inside the package (`inst/`), so a +port has no dependency on the staged real-framework data under `data/raw/` +or `data/processed/` (both gitignored). + +## Files + +- `fixture.nt` -- a hand-authored N-Triples graph. Two workforce + frameworks (`fixture-wf1`, `fixture-wf2`, each with two `cybed:Role` + organizing units), one pedagogy framework (`fixture-ped1`, one + organizing unit that is its own top-level statement, with one + `cybed:Subpoint` and one `cybed:Example` child). It also encodes: + a statement (`element/fixture-el-role1`) shared by two units across two + frameworks (`role/fixture-role-2` and `role/fixture-role-4` both link to + it via `cybed:hasElement`, in addition to their own text); one + `cybed:UnitRelation` (`fixture-role-1` `requires` `fixture-role-3`); + non-ASCII text (accented French on the pedagogy unit and its Subpoint, + N-Triples-escaped as `\uXXXX` per the N-Triples spec, which is why the + file reads as plain ASCII bytes); and text engineered so + `framework_similarity()` produces multiple non-zero, differently-tiered + scores between `fixture-wf1` and `fixture-wf2` (all four cross-framework + role pairs share the token `network`/`traffic`/`incident*`, at varying + overlap). +- `mock-release/1.0.0/` -- a release directory in exactly the shape + `scripts/030-export-release.R` produces: `manifest.json` plus one + gzipped N-Triples file per framework (`fixture-wf1.nt.gz`, + `fixture-wf2.nt.gz`, `fixture-ped1.nt.gz`), each framework's + `sha256`/`sha256_uncompressed` matching the manifest. Used by + `cybed_fetch()`/`load_graph()` tests via a `file://` URL + (`options(cybedtools.release_url = ...)`), so no test call reaches the + network. +- `goldens/*.csv` -- one file per public API function, run against + `fixture.nt`. Regenerated by `scripts/040-build-goldens.R`; never hand-edited. + +## Regenerating + +```r +Rscript scripts/040-build-goldens.R +``` + +Rebuilds `fixture.nt`, the mock release, and every golden CSV from the +in-memory fixture-graph builder in that script (the single source of +truth for the fixture; `fixture.nt` is its serialization). + +After regenerating, sync the copies the Python port tests against, from the +repository root: + +```sh +cp inst/conformance/goldens/*.csv python/tests/fixtures/conformance/goldens/ +cp inst/conformance/fixture.nt python/tests/fixtures/conformance/fixture.nt +``` + +Then run both suites: + +```r +devtools::test() +``` + +```sh +cd python && .venv/Scripts/python.exe -m pytest tests/ +``` + +`python/tests/test_conformance.py::test_fixture_copy_matches_source` fails +loudly if the packaged copy under `python/tests/fixtures/conformance/` +drifts from this directory, so a missed copy step does not pass silently. + +## The golden format contract (exact, for a Python port) + +Every file in `goldens/` is: + +- **Encoding**: UTF-8, no byte-order mark. +- **Line endings**: LF (`\n`) only. +- **Header row**: present, exact column names below, in the exact order + listed (this is also each function's public column order). +- **NA encoding**: an empty field (`,,`), never the literal text `NA`. +- **Numeric rounding**: any similarity `score` column is rounded to + **10 decimal places** before being written (R: `round(x, 10)`). No other + numeric columns exist in the current goldens. +- **Sort key**: rows are sorted ascending, exactly as given below, before + being written. A tie within the sort key is left in whatever order the + upstream `dplyr` pipeline produced (all current sort keys are unique per + row, so this does not arise for the shipped fixture). +- **CSV dialect**: comma-delimited, double-quote quoting only where a field + requires it (a comma, quote, or newline in the value), quotes doubled to + escape (RFC 4180 / `readr::write_csv()` defaults). + +### `framework_metadata.csv` + +Columns: `framework` (string, IRI), `name` (string), `jurisdiction` +(string), `sector` (string), `specificity` (string), `framework_slug` +(string). Sort key: `framework`. + +### `unit_element_bindings.csv` + +Columns: `role` (string, IRI -- the organizing unit), `element` (string, +IRI), `framework_slug` (string, may be empty if the unit has no valid +`cybed:partOf` to a `cybed:Framework`). Sort key: `role`, then `element`. + +### `unit_relation_bindings.csv` + +Columns: `from_unit` (string, IRI), `relation` (string, may be empty), +`to_unit` (string, IRI), `framework_slug` (string, may be empty). Sort +key: `from_unit`, then `to_unit`, then `relation`. + +### `framework_similarity.csv` + +Produced by `framework_similarity(rdf, from = "fixture-wf1", to = +"fixture-wf2", n = 5)`. Columns: `from_unit` (string, IRI), `to_unit` +(string, IRI), `score` (numeric in `[0, 1]`, rounded to 10 decimal +places), `strength` (one of `"strong"`, `"moderate"`, `"weak"`, `"none"`), +`rank` (integer, `1..k`, dense per `from_unit`, ties broken by ascending +`to_unit`). Sort key: `from_unit`, then `rank`. + +### `cybed_license.csv` + +The full result of `cybed_license()` (no argument) against the package's +real shipped `framework_licenses` data (this golden is independent of +`fixture.nt` -- there is no fixture-scale licensing table). Columns and +types match `?framework_licenses`. Sort key: `slug`. + +### `framework_summary.csv` + +The package's real shipped `framework_summary` data tibble (also +independent of `fixture.nt`, for the same reason: it is precomputed from +the full eleven-framework corpus at package-build time, not derivable from +a small hand fixture). Columns and types match `?framework_summary`. Sort +key: `framework_slug`. + +### `role_framework_bindings.csv` + +Columns: `role` (string, IRI), `role_name` (string, may be empty), +`framework` (string, IRI), `framework_name` (string, may be empty), +`framework_slug` (string). Sort key: `role`, then `framework`. + +### `organizing_unit_framework_bindings.csv` + +Columns: `unit` (string, IRI), `unit_name` (string, may be empty), +`framework` (string, IRI), `framework_name` (string, may be empty), +`framework_slug` (string). Sort key: `unit`, then `framework`. + +### `element_framework_bindings.csv` + +Columns: `element` (string, IRI), `framework` (string, IRI), +`framework_name` (string, may be empty), `framework_slug` (string). Sort +key: `element`, then `framework`. + +### `example_framework_bindings.csv` + +Columns: `example` (string, IRI), `framework` (string, IRI), +`framework_name` (string, may be empty), `framework_slug` (string). Sort +key: `example`, then `framework`. + +### `subpoint_framework_bindings.csv` + +Columns: `subpoint` (string, IRI), `framework` (string, IRI), +`framework_name` (string, may be empty), `framework_slug` (string). Sort +key: `subpoint`, then `framework`. + +### `element_text.csv` + +Columns: `element` (string, IRI), `text` (string, the statement literal; +includes the fixture's non-ASCII French text). Sort key: `element`. + +## Deliberately out of scope for these goldens + +`cybed_fetch()` and `load_graph()` are network/filesystem-effecting, not +pure query functions over a loaded graph, so they have no golden CSV. +Their conformance test is instead: point `cybedtools.release_url` at +`mock-release/`, fetch, and assert the cached file's hash matches the +manifest (see `tests/testthat/test-cybed-fetch.R`). diff --git a/inst/conformance/fixture.nt b/inst/conformance/fixture.nt new file mode 100644 index 0000000..f1c9d02 --- /dev/null +++ b/inst/conformance/fixture.nt @@ -0,0 +1,72 @@ + . + "Monitors network traffic for security incidents and analyzes alerts" . + . + . + "Configures firewalls and defends the network perimeter" . + . + . + "Monitors network traffic for incidents and coordinates incident response" . + . + . + "Hunts for adversaries hiding in network traffic and endpoint logs" . + . + . + . + "Clarification statement: includes GDPR-relevant scenarios" . + . + . + . + . + "chiffrement et contr\u00F4le d'acc\u00E8s" . + . + . + "Prot\u00E9ger les donn\u00E9es personnelles et respecter la vie priv\u00E9e" . + . + "Fixture Pedagogy Framework One" . + . + "global" . + "education" . + "cybersecurity-specific" . + "Fixture Workforce Framework One" . + . + "US" . + "civilian" . + "cybersecurity-specific" . + "Fixture Workforce Framework Two" . + . + "EU" . + "civilian" . + "cybersecurity-specific" . + . + . + . + "requires" . + . + "Security Analyst" . + . + . + . + . + "Network Defender" . + . + . + . + . + . + "Incident Handler" . + . + . + . + . + "Threat Hunter" . + . + . + . + . + . + "Grade Band Data Protection Cell" . + . + . + . + . + . diff --git a/inst/conformance/goldens/cybed_license.csv b/inst/conformance/goldens/cybed_license.csv new file mode 100644 index 0000000..1249e9a --- /dev/null +++ b/inst/conformance/goldens/cybed_license.csv @@ -0,0 +1,16 @@ +layer,slug,framework_name,license_short,license,attribution,public_redistribution,terms_url,granted,verified +framework,ccssf-2022,CCSSF 2022,"Government of Canada, with permission","The Canadian Cyber Security Skills Framework (ITSM.00.039) carries no licence notice of any kind: a sweep of the document finds no copyright line, no Creative Commons statement, no Open Government Licence reference and no all-rights-reserved statement. It carries only the Government of Canada publication identifiers (ISBN 978-0-660-46231-8, Cat. No. D97-4/00-039-2022E-PDF). The document is marked ""TLP:CLEAR"" and ""UNCLASSIFIED / NON CLASSIFIE"" on every page; both are disclosure markings rather than copyright licences. The Canadian Centre for Cyber Security gave cybedtools written permission on 2026-09-21 for full-text publication with attribution.","Canadian Centre for Cyber Security, The Canadian Cyber Security Skills Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada. Used with permission of the Canadian Centre for Cyber Security.",full_with_attribution,https://www.cyber.gc.ca/en/publications,TRUE,2026-09-21 +framework,csec2017-v1,ACM/IEEE CSEC2017,All rights reserved; educational use,"From the CSEC2017 Curricular Guidelines PDF's own copyright page: ""Copyright (c) 2017 by ACM, IEEE, AIS, IFIP / ALL RIGHTS RESERVED / Copyright and Reprint Permissions: Permission is granted to use these curricular guidelines for the development of educational materials and programs. Other use requires specific permission."" (ISBN 978-1-4503-5278-9, DOI 10.1145/3184594.) The grant is limited by purpose rather than by who may use it, and it does not extend to republishing the guidelines themselves, so cybedtools publishes Knowledge Area names, counts and alignment scores and no statement text.",,structure_only,https://cybered.acm.org/,FALSE,2026-09-18 +framework,csta-2017,CSTA K-12 CS (Rev 2017),CC BY-NC-SA 4.0,"The ingested XLSX carries no licence statement, so the label is read from CSTA's 2017-edition publication page: ""These Standards are licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License (CC BY-NC-SA 4.0)."" The share-alike term propagates: any file containing CSTA-derived content must itself go out under CC BY-NC-SA 4.0, which is why cybedtools publishes one graph file per framework rather than one combined file.","Computer Science Teachers Association (2017). CSTA K-12 Computer Science Standards, Revised 2017. Retrieved from https://csteachers.org/k12standards/.",full_with_attribution,https://creativecommons.org/licenses/by-nc-sa/4.0/,FALSE,2026-09-18 +framework,csta-2026,CSTA PK-12 CS (2026),CC BY-NC-SA 4.0,"From the 2026 CSTA PK-12 Computer Science Standards PDF's own licence page (p. iv): ""These Standards are licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. Accordingly, individuals and organizations are free to download, print, share, and adapt the materials in whole or in part, as long as they provide proper attribution, use for non-commercial purposes, and share contributions or derivations under the same license."" Every page footer repeats ""CC BY-NC-SA 4.0"". cybedtools ingests the JSON behind CSTA's interactive Standards Explorer, which carries the same standard text and is published by CSTA, so it is treated under the same terms. The share-alike term propagates to any file containing CSTA-derived content. The attribution below is the PDF's own suggested citation and DOI, with the en dash in PK-12 rendered as a hyphen.",Computer Science Teachers Association. (2026). 2026 CSTA PK-12 computer science standards. https://csteachers.org/pk12standards/ DOI: https://doi.org/10.1145/3820482,full_with_attribution,https://creativecommons.org/licenses/by-nc-sa/4.0/,FALSE,2026-09-21 +code,cybedtools,cybedtools (R package code),MIT,"LICENSE.md, the package's own licence file: ""MIT License / Copyright (c) 2026 Ryan Straight / Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ""Software""), to deal in the Software without restriction..."" The MIT licence covers the code in this repository only. It does not reach any framework's source text, which carries the framework's own terms.",Copyright (c) 2026 Ryan Straight. MIT License.,unrestricted,https://github.com/ryanstraight/cybedtools/blob/main/LICENSE.md,FALSE,2026-09-19 +framework,cyberorg-k12-v1.0,Cyber.org K-12 v1.0,CC BY-NC 4.0,"From the K-12 Cybersecurity Learning Standards PDF's own version-control page: ""K-12 Cybersecurity Learning Standards (c) 2021 by Cyber Innovation Center & CYBER.ORG is licensed under Creative Commons Attribution-NonCommercial 4.0 International"". The same page carries a separate and unconditional grant alongside the licence: ""Authorization to reproduce this report in whole or in part is granted."" The cyber.org/standards page itself states no terms, so the PDF is the only source.",K-12 Cybersecurity Learning Standards. (2021). Retrieved from https://cyber.org/standards.,full_with_attribution,https://creativecommons.org/licenses/by-nc/4.0/,FALSE,2026-09-18 +framework,cybok-v1.1.0,CyBOK v1.1.0,OGL v3.0,"From the copyright page of the Introduction to CyBOK Knowledge Area, Version 1.1.0 (July 2021): ""© Crown Copyright, The National Cyber Security Centre 2021. This information is licensed under the Open Government Licence v3.0."" The same page prescribes the attribution below. The A-to-Z Indicative Material document and each Knowledge Tree carry the same Crown Copyright and Open Government Licence v3.0 notice. The Open Government Licence v3.0 permits copying, publishing, adapting and commercial use on the condition of attribution.","CyBOK © Crown Copyright, The National Cyber Security Centre 2021, licensed under the Open Government Licence: http://www.nationalarchives.gov.uk/doc/open-government-licence/.",full_with_attribution,https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/,FALSE,2026-09-21 +framework,cyqual-v1.2.0,CyQUAL 1.2.0,"Open data, attribution required","The open-data JSON export carries no rights field of any kind. The CyQUAL team at Masaryk University confirmed in writing on 2026-09-16 that the framework is published as open data and publicly available, that a derived RDF/JSON-LD graph may be built from the export, and that the only condition is attribution to CyQUAL and to Masaryk University.","CyQUAL, the Czech national cybersecurity qualifications framework, developed at Masaryk University. Open data, version 1.2.0, https://platform.cyqual.cz/.",full_with_attribution,https://platform.cyqual.cz/en,TRUE,2026-09-18 +framework,dcwf-v5.1,DCWF v5.1,"US Government work, no US copyright","The DoD Cyber Workforce Framework Work Role Tool v5.1 workbook carries no rights or distribution statement in any of its 79 sheets, and the DoD download pages sit behind authentication, so no download-page statement could be read as of 2026-09-18. The governing position is therefore inferred rather than quoted: a work of the US Government prepared by DoD personnel is not subject to copyright in the United States under 17 U.S.C. 105. Nine work roles carry a ""(CUI)"" tag in the workbook's role index; review those sheets before any public deposit of DCWF-derived content.",,unrestricted,https://www.cyberworkforce.mil/Department-Cyber-Workforce-Framework/,FALSE,2026-09-18 +framework,digcomp-3.0,DigComp 3.0,CC BY 4.0,"From the copyright notice distributed with the official DigComp 3.0 data supplement (JRC144121, ISBN 978-92-68-32677-0): ""(c) European Union, 1995-2026 / The Commission's reuse policy is implemented by the Commission Decision of 12 December 2011 on the reuse of Commission documents. Any copyright and/or sui generis right on the dataset is licensed under the Creative Commons Attribution 4.0 International (CC BY 4.0) licence. Reuse is allowed provided appropriate credit is given and any changes are indicated."" Also confirmed on the JRC Data Catalogue record (DOI 10.2905/JRC.FR75K8R) and the DigComp 3.0 resources page, which additionally excludes the European Commission logo from reuse; cybedtools reproduces no logo.","Cosgrove, J. and Cachia, R., DigComp 3.0: The Digital Competence Framework for Citizens, EUR 40491, Publications Office of the European Union, Luxembourg, 2025, ISBN 978-92-68-32677-0, doi:10.2760/0001149. Dataset: doi:10.2905/JRC.FR75K8R.",full_with_attribution,https://creativecommons.org/licenses/by/4.0/,FALSE,2026-09-21 +framework,ecsf-v1,ECSF v1,CC BY 4.0 (report); data files unmarked,"Two things are true. The ECSF Role Profiles report PDF (September 2022, ISBN 978-92-9204-584-5, DOI 10.2824/859537) carries its own copyright notice: ""(c) European Union Agency for Cybersecurity (ENISA), 2022 / This publication is licenced under CC-BY 4.0 ... the reuse of this document is authorised under the Creative Commons Attribution 4.0 International (CC BY 4.0) licence"", and its legal notice adds ""All references to it or its use as a whole or partially must contain ENISA as its source."" The JSON and XLSX files cybedtools ingests carry no notice of any kind, and rest instead on ENISA's site-wide legal notice: ""Reproduction of ENISA material published on this website is authorized, provided the source is acknowledged, unless it is stated otherwise."" ENISA prescribes no citation wording for the report or the data files, so the attribution below is the package's own and follows what the CC BY 4.0 licence requires.","European Union Agency for Cybersecurity (ENISA), European Cybersecurity Skills Framework (ECSF) Role Profiles, September 2022, ISBN 978-92-9204-584-5, DOI 10.2824/859537. (c) ENISA 2022, licensed under CC BY 4.0. Source: https://www.enisa.europa.eu/publications/european-cybersecurity-skills-framework-role-profiles",full_with_attribution,https://www.enisa.europa.eu/publications/european-cybersecurity-skills-framework-role-profiles,FALSE,2026-09-19 +framework,nice-v2,NICE v2.2.0,US public domain + NIST worldwide grant,"The ingested CPRT JSON carries no rights statement, so NIST's published statements govern. NIST, ""Copyright, Fair Use, and Licensing Statements for SRD, Data, Software and Technical Series Publications"", Technical Series section: ""Works authored by NIST employees are not subject to Copyright protection within the United States; foreign rights are reserved. To the extent NIST may assert rights outside of the United States, the public is granted the non-exclusive, perpetual, paid-up, royalty-free, worldwide right to reprint works in all formats including print, electronically, and online, and in all subsequent editions, and derivative works."" NIST's copyright page adds that ""Use of appropriate byline/photo/image credits is requested.""",,unrestricted,https://www.nist.gov/open/copyright-fair-use-and-licensing-statements-srd-data-software-and-technical-series-publications,FALSE,2026-09-18 +framework,otccf-v1.1,OTCCF v1.1,CSA copyright; non-commercial academic,"""(c) Cyber Security Agency of Singapore"" appears on every page of the Operational Technology Cybersecurity Competency Framework PDF, and the document carries no licence beyond that copyright line. The Cyber Security Agency of Singapore granted cybedtools written permission on 2026-09-15 to reference and integrate the OTCCF's structure for non-commercial, academic and research purposes, on two conditions: the attribution wording below word for word, and that the ingestion scripts and structural mappings not misrepresent or alter the intent of the OTCCF's job roles, skills, or competency mappings as published by CSA. The grant covers structure only: roles, skill titles, categories, proficiency levels and role-to-skill mappings, not statement text. The document names Mercer Singapore as a joint developer; whether Mercer holds any interest CSA's grant does not reach is unresolved.","Derived from the Operational Technology Cybersecurity Competency Framework (OTCCF), published by the Cyber Security Agency of Singapore (CSA). Available at: https://www.csa.gov.sg/resources/publications/operational-technology-cybersecurity-competency-framework--otccf-/",structure_only,https://www.csa.gov.sg/resources/publications/operational-technology-cybersecurity-competency-framework--otccf-/,TRUE,2026-09-18 +framework,scywf-1.5,SCyWF 1.5,NCA written permission,"The Saudi Cybersecurity Workforce Framework (SCyWF – 1.5: 2026) PDF carries no copyright line and no licence notice. Its only markings are ""TLP: Clear"" and ""Document Classification: Public"", which are disclosure markings rather than licences. The National Cybersecurity Authority (NCA) of the Kingdom of Saudi Arabia granted cybedtools written permission on 2026-09-20 to ingest and redistribute the framework's content, on three conditions. (1) NCA is cited as issuing body and source, with a link to the official SCyWF page. (2) Everything taken from the document (identifiers, titles, statement text) is carried verbatim, with no normalised labels, paraphrase, in-house translation or composed text presented as NCA content. (3) Anything cybedtools derives, such as hierarchy edges or cross-framework mappings, is marked as derived and not presented as NCA content. NCA prescribed no attribution wording, so the attribution below is composed to meet the first condition. The grant named an earlier edition (SCyWF - 1 : 2020) that NCA no longer publishes. The current edition is ingested by owner decision. The document's own disclaimer makes its Arabic version binding for matters of meaning and interpretation. cybedtools ingests the English version.","The Saudi Cybersecurity Workforce Framework (SCyWF – 1.5: 2026). Issued by the National Cybersecurity Authority (NCA), Kingdom of Saudi Arabia. Source: https://nca.gov.sa/en/pages/scywf.html",full_with_attribution,https://nca.gov.sa/en/pages/scywf.html,TRUE,2026-09-21 +framework,sfia-9,SFIA 9,SFIA Foundation licence required,"SFIA Foundation, ""Using and licensing SFIA"": ""Note: All use of SFIA is under licence."" / ""The SFIA Framework, guidance and support assets are the intellectual property of the SFIA Foundation."" / ""Copying of this material is prohibited unless authorised in writing or under a valid SFIA licence obtained from the SFIA Foundation."" The SFIA General Terms and Conditions define the protected material as ""The concept, content and structure of SFIA along with content from the SFIA website"", and name ""a skills database containing SFIA information"" as a royalty-bearing dependent product, so there is no structure-versus-text carve-out. The third-party structural extract cybedtools reads carries no licence file of any kind and holds no SFIA licence it could pass on. SFIA content in cybedtools is local-analysis only; nothing beyond aggregate counts is published.",,local_only,https://sfia-online.org/en/about-sfia/licensing-sfia/using-and-licensing-sfia,FALSE,2026-09-18 diff --git a/inst/conformance/goldens/element_framework_bindings.csv b/inst/conformance/goldens/element_framework_bindings.csv new file mode 100644 index 0000000..1d2d065 --- /dev/null +++ b/inst/conformance/goldens/element_framework_bindings.csv @@ -0,0 +1,8 @@ +element,framework,framework_name,framework_slug +https://w3id.org/cybed/ontology#element/fixture-el-role1,https://w3id.org/cybed/ontology#framework/fixture-wf1,Fixture Workforce Framework One,fixture-wf1 +https://w3id.org/cybed/ontology#element/fixture-el-role2,https://w3id.org/cybed/ontology#framework/fixture-wf1,Fixture Workforce Framework One,fixture-wf1 +https://w3id.org/cybed/ontology#element/fixture-el-role3,https://w3id.org/cybed/ontology#framework/fixture-wf2,Fixture Workforce Framework Two,fixture-wf2 +https://w3id.org/cybed/ontology#element/fixture-el-role4,https://w3id.org/cybed/ontology#framework/fixture-wf2,Fixture Workforce Framework Two,fixture-wf2 +https://w3id.org/cybed/ontology#element/fixture-el-unit5,https://w3id.org/cybed/ontology#framework/fixture-ped1,Fixture Pedagogy Framework One,fixture-ped1 +https://w3id.org/cybed/ontology#element/fixture-el-unit5.example.1,https://w3id.org/cybed/ontology#framework/fixture-ped1,Fixture Pedagogy Framework One,fixture-ped1 +https://w3id.org/cybed/ontology#element/fixture-el-unit5.sub.1,https://w3id.org/cybed/ontology#framework/fixture-ped1,Fixture Pedagogy Framework One,fixture-ped1 diff --git a/inst/conformance/goldens/element_text.csv b/inst/conformance/goldens/element_text.csv new file mode 100644 index 0000000..aacc6cf --- /dev/null +++ b/inst/conformance/goldens/element_text.csv @@ -0,0 +1,8 @@ +element,text +https://w3id.org/cybed/ontology#element/fixture-el-role1,Monitors network traffic for security incidents and analyzes alerts +https://w3id.org/cybed/ontology#element/fixture-el-role2,Configures firewalls and defends the network perimeter +https://w3id.org/cybed/ontology#element/fixture-el-role3,Monitors network traffic for incidents and coordinates incident response +https://w3id.org/cybed/ontology#element/fixture-el-role4,Hunts for adversaries hiding in network traffic and endpoint logs +https://w3id.org/cybed/ontology#element/fixture-el-unit5,Protéger les données personnelles et respecter la vie privée +https://w3id.org/cybed/ontology#element/fixture-el-unit5.example.1,Clarification statement: includes GDPR-relevant scenarios +https://w3id.org/cybed/ontology#element/fixture-el-unit5.sub.1,chiffrement et contrôle d'accès diff --git a/inst/conformance/goldens/example_framework_bindings.csv b/inst/conformance/goldens/example_framework_bindings.csv new file mode 100644 index 0000000..888b9cb --- /dev/null +++ b/inst/conformance/goldens/example_framework_bindings.csv @@ -0,0 +1,2 @@ +example,framework,framework_name,framework_slug +https://w3id.org/cybed/ontology#element/fixture-el-unit5.example.1,https://w3id.org/cybed/ontology#framework/fixture-ped1,Fixture Pedagogy Framework One,fixture-ped1 diff --git a/inst/conformance/goldens/framework_metadata.csv b/inst/conformance/goldens/framework_metadata.csv new file mode 100644 index 0000000..c20de1e --- /dev/null +++ b/inst/conformance/goldens/framework_metadata.csv @@ -0,0 +1,4 @@ +framework,name,jurisdiction,sector,specificity,framework_slug +https://w3id.org/cybed/ontology#framework/fixture-ped1,Fixture Pedagogy Framework One,global,education,cybersecurity-specific,fixture-ped1 +https://w3id.org/cybed/ontology#framework/fixture-wf1,Fixture Workforce Framework One,US,civilian,cybersecurity-specific,fixture-wf1 +https://w3id.org/cybed/ontology#framework/fixture-wf2,Fixture Workforce Framework Two,EU,civilian,cybersecurity-specific,fixture-wf2 diff --git a/inst/conformance/goldens/framework_similarity.csv b/inst/conformance/goldens/framework_similarity.csv new file mode 100644 index 0000000..a55964e --- /dev/null +++ b/inst/conformance/goldens/framework_similarity.csv @@ -0,0 +1,5 @@ +from_unit,to_unit,score,strength,rank +https://w3id.org/cybed/ontology#role/fixture-role-1,https://w3id.org/cybed/ontology#role/fixture-role-4,0.4666666667,strong,1 +https://w3id.org/cybed/ontology#role/fixture-role-1,https://w3id.org/cybed/ontology#role/fixture-role-3,0.3333333333,strong,2 +https://w3id.org/cybed/ontology#role/fixture-role-2,https://w3id.org/cybed/ontology#role/fixture-role-4,0.3684210526,strong,1 +https://w3id.org/cybed/ontology#role/fixture-role-2,https://w3id.org/cybed/ontology#role/fixture-role-3,0.25,moderate,2 diff --git a/inst/conformance/goldens/framework_summary.csv b/inst/conformance/goldens/framework_summary.csv new file mode 100644 index 0000000..fcbc5a8 --- /dev/null +++ b/inst/conformance/goldens/framework_summary.csv @@ -0,0 +1,15 @@ +framework_slug,framework_name,framework_type,jurisdiction,organizing_unit_count,role_count,element_count_strict,subpoint_count,example_count,element_count_with_examples,unit_relation_count,elements_per_organizing_unit_strict,elements_per_organizing_unit_with_examples,elements_per_role_strict,elements_per_role_with_examples,license +ccssf-2022,CCSSF 2022,workforce,CA,59,59,1148,197,0,1345,0,19.5,22.8,19.5,22.8,"Government of Canada, with permission" +csec2017-v1,ACM/IEEE CSEC2017,pedagogy,global,8,,38,2,0,40,0,4.8,5,,,All rights reserved; educational use +csta-2017,CSTA K-12 CS (Rev 2017),pedagogy,US,25,,120,24,114,258,0,4.8,10.3,,,CC BY-NC-SA 4.0 +csta-2026,CSTA PK-12 CS (2026),pedagogy,US,53,,331,0,652,983,0,6.2,18.5,,,CC BY-NC-SA 4.0 +cyberorg-k12-v1.0,Cyber.org K-12 v1.0,pedagogy,US,116,,123,0,369,492,0,1.1,4.2,,,CC BY-NC 4.0 +cybok-v1.1.0,CyBOK v1.1.0,pedagogy,UK,21,,119,477,0,596,0,5.7,28.4,,,OGL v3.0 +cyqual-v1.2.0,CyQUAL 1.2.0,workforce,CZ,161,102,2488,852,0,3340,0,15.5,20.7,22.9,30.9,"Open data, attribution required" +dcwf-v5.1,DCWF v5.1,workforce,US,74,74,2945,1107,0,4052,0,39.8,54.8,39.8,54.8,"US Government work, no US copyright" +digcomp-3.0,DigComp 3.0,pedagogy,EU,26,,362,0,522,884,0,13.9,34,,,CC BY 4.0 +ecsf-v1,ECSF v1,workforce,EU,12,12,374,16,0,390,0,31.2,32.5,31.2,32.5,CC BY 4.0 (report); data files unmarked +nice-v2,NICE v2.2.0,workforce,US,53,42,2211,14,0,2225,0,41.7,42,44.4,44.7,US public domain + NIST worldwide grant +otccf-v1.1,OTCCF v1.1,workforce,SG,61,15,1612,0,0,1612,310,26.4,26.4,18,18,CSA copyright; non-commercial academic +scywf-1.5,SCyWF 1.5,workforce,SA,81,40,1419,0,0,1419,0,17.5,17.5,35.5,35.5,NCA written permission +sfia-9,SFIA 9,workforce,global,147,,672,149,0,821,0,4.6,5.6,,,SFIA Foundation licence required diff --git a/inst/conformance/goldens/organizing_unit_framework_bindings.csv b/inst/conformance/goldens/organizing_unit_framework_bindings.csv new file mode 100644 index 0000000..3bd0366 --- /dev/null +++ b/inst/conformance/goldens/organizing_unit_framework_bindings.csv @@ -0,0 +1,6 @@ +unit,framework,unit_name,framework_name,framework_slug +https://w3id.org/cybed/ontology#role/fixture-role-1,https://w3id.org/cybed/ontology#framework/fixture-wf1,Security Analyst,Fixture Workforce Framework One,fixture-wf1 +https://w3id.org/cybed/ontology#role/fixture-role-2,https://w3id.org/cybed/ontology#framework/fixture-wf1,Network Defender,Fixture Workforce Framework One,fixture-wf1 +https://w3id.org/cybed/ontology#role/fixture-role-3,https://w3id.org/cybed/ontology#framework/fixture-wf2,Incident Handler,Fixture Workforce Framework Two,fixture-wf2 +https://w3id.org/cybed/ontology#role/fixture-role-4,https://w3id.org/cybed/ontology#framework/fixture-wf2,Threat Hunter,Fixture Workforce Framework Two,fixture-wf2 +https://w3id.org/cybed/ontology#unit/fixture-unit-5,https://w3id.org/cybed/ontology#framework/fixture-ped1,Grade Band Data Protection Cell,Fixture Pedagogy Framework One,fixture-ped1 diff --git a/inst/conformance/goldens/role_framework_bindings.csv b/inst/conformance/goldens/role_framework_bindings.csv new file mode 100644 index 0000000..ee52d93 --- /dev/null +++ b/inst/conformance/goldens/role_framework_bindings.csv @@ -0,0 +1,5 @@ +role,framework,role_name,framework_name,framework_slug +https://w3id.org/cybed/ontology#role/fixture-role-1,https://w3id.org/cybed/ontology#framework/fixture-wf1,Security Analyst,Fixture Workforce Framework One,fixture-wf1 +https://w3id.org/cybed/ontology#role/fixture-role-2,https://w3id.org/cybed/ontology#framework/fixture-wf1,Network Defender,Fixture Workforce Framework One,fixture-wf1 +https://w3id.org/cybed/ontology#role/fixture-role-3,https://w3id.org/cybed/ontology#framework/fixture-wf2,Incident Handler,Fixture Workforce Framework Two,fixture-wf2 +https://w3id.org/cybed/ontology#role/fixture-role-4,https://w3id.org/cybed/ontology#framework/fixture-wf2,Threat Hunter,Fixture Workforce Framework Two,fixture-wf2 diff --git a/inst/conformance/goldens/subpoint_framework_bindings.csv b/inst/conformance/goldens/subpoint_framework_bindings.csv new file mode 100644 index 0000000..08d891d --- /dev/null +++ b/inst/conformance/goldens/subpoint_framework_bindings.csv @@ -0,0 +1,2 @@ +subpoint,framework,framework_name,framework_slug +https://w3id.org/cybed/ontology#element/fixture-el-unit5.sub.1,https://w3id.org/cybed/ontology#framework/fixture-ped1,Fixture Pedagogy Framework One,fixture-ped1 diff --git a/inst/conformance/goldens/unit_element_bindings.csv b/inst/conformance/goldens/unit_element_bindings.csv new file mode 100644 index 0000000..1d94ce0 --- /dev/null +++ b/inst/conformance/goldens/unit_element_bindings.csv @@ -0,0 +1,9 @@ +role,element,framework_slug +https://w3id.org/cybed/ontology#role/fixture-role-1,https://w3id.org/cybed/ontology#element/fixture-el-role1,fixture-wf1 +https://w3id.org/cybed/ontology#role/fixture-role-2,https://w3id.org/cybed/ontology#element/fixture-el-role1,fixture-wf1 +https://w3id.org/cybed/ontology#role/fixture-role-2,https://w3id.org/cybed/ontology#element/fixture-el-role2,fixture-wf1 +https://w3id.org/cybed/ontology#role/fixture-role-3,https://w3id.org/cybed/ontology#element/fixture-el-role3,fixture-wf2 +https://w3id.org/cybed/ontology#role/fixture-role-4,https://w3id.org/cybed/ontology#element/fixture-el-role1,fixture-wf2 +https://w3id.org/cybed/ontology#role/fixture-role-4,https://w3id.org/cybed/ontology#element/fixture-el-role4,fixture-wf2 +https://w3id.org/cybed/ontology#unit/fixture-unit-5,https://w3id.org/cybed/ontology#element/fixture-el-unit5,fixture-ped1 +https://w3id.org/cybed/ontology#unit/fixture-unit-5,https://w3id.org/cybed/ontology#element/fixture-el-unit5.sub.1,fixture-ped1 diff --git a/inst/conformance/goldens/unit_relation_bindings.csv b/inst/conformance/goldens/unit_relation_bindings.csv new file mode 100644 index 0000000..722168d --- /dev/null +++ b/inst/conformance/goldens/unit_relation_bindings.csv @@ -0,0 +1,2 @@ +from_unit,relation,to_unit,framework_slug +https://w3id.org/cybed/ontology#role/fixture-role-1,requires,https://w3id.org/cybed/ontology#role/fixture-role-3,fixture-wf1 diff --git a/inst/conformance/mock-release/1.0.0/fixture-ped1.nt.gz b/inst/conformance/mock-release/1.0.0/fixture-ped1.nt.gz new file mode 100644 index 0000000..b01c9a5 Binary files /dev/null and b/inst/conformance/mock-release/1.0.0/fixture-ped1.nt.gz differ diff --git a/inst/conformance/mock-release/1.0.0/fixture-wf1.nt.gz b/inst/conformance/mock-release/1.0.0/fixture-wf1.nt.gz new file mode 100644 index 0000000..a27df20 Binary files /dev/null and b/inst/conformance/mock-release/1.0.0/fixture-wf1.nt.gz differ diff --git a/inst/conformance/mock-release/1.0.0/fixture-wf2.nt.gz b/inst/conformance/mock-release/1.0.0/fixture-wf2.nt.gz new file mode 100644 index 0000000..2299565 Binary files /dev/null and b/inst/conformance/mock-release/1.0.0/fixture-wf2.nt.gz differ diff --git a/inst/conformance/mock-release/1.0.0/manifest.json b/inst/conformance/mock-release/1.0.0/manifest.json new file mode 100644 index 0000000..63d0bb2 --- /dev/null +++ b/inst/conformance/mock-release/1.0.0/manifest.json @@ -0,0 +1,34 @@ +{ + "manifest_version": "1", + "release_version": "1.0.0", + "release_date": "2026-09-21", + "files": [ + { + "slug": "fixture-wf1", + "file": "fixture-wf1.nt.gz", + "triples": 5, + "bytes": 215, + "bytes_uncompressed": 618, + "sha256": "aafb41e8847cddec9d294f2c5ed753d962c35e2e9533a2d6a32bf405bf4c90a4", + "sha256_uncompressed": "f383dd4be844597084ba9f2b241c2a0b210bef1660ddd6dc9c35e91705d0e6b4" + }, + { + "slug": "fixture-wf2", + "file": "fixture-wf2.nt.gz", + "triples": 5, + "bytes": 215, + "bytes_uncompressed": 618, + "sha256": "9a7bc7b6b81feb152a35bdacb145985478801f0dc1b2990d545c2c2105fe9a87", + "sha256_uncompressed": "66b2ca5ca2cf8ae64a3a0b15c8cf4f010d518ab1fe0b7ec98275d0bd0e2abc25" + }, + { + "slug": "fixture-ped1", + "file": "fixture-ped1.nt.gz", + "triples": 5, + "bytes": 216, + "bytes_uncompressed": 627, + "sha256": "56558a4d6c09670e4f9701c786eceefbde79c21d24a1727f417f1accc9f041af", + "sha256_uncompressed": "70b15bd3a3b0cb0a8f1c693642fa85ab0f8d7d5d149bf808715658bf0049e37d" + } + ] +} diff --git a/man/build_jsonld_context.Rd b/man/build_jsonld_context.Rd index 65f4b82..3c336df 100644 --- a/man/build_jsonld_context.Rd +++ b/man/build_jsonld_context.Rd @@ -9,8 +9,9 @@ build_jsonld_context(framework_prefix) \arguments{ \item{framework_prefix}{Character, one of the valid framework prefixes. Workforce: \code{"nice"}, \code{"dcwf"}, \code{"ecf"}, \code{"sfia"}, \code{"ecsf"}, \code{"cyqual"}, -\code{"ccssf"}, \code{"otccf"}. -Pedagogical: \code{"cyberorg"}, \code{"csta"}, \code{"csec"}, \code{"digcomp"}.} +\code{"ccssf"}, \code{"otccf"}, \code{"scywf"}. +Pedagogical: \code{"cyberorg"}, \code{"csta"}, \code{"csta2026"}, \code{"csec"}, \code{"digcomp"}, +\code{"cybok"}.} } \value{ Named list suitable for use as JSON-LD \verb{@context}. diff --git a/man/build_organizing_unit_node.Rd b/man/build_organizing_unit_node.Rd index a8ec839..491e927 100644 --- a/man/build_organizing_unit_node.Rd +++ b/man/build_organizing_unit_node.Rd @@ -67,11 +67,11 @@ Named list (JSON-LD node). Every framework's top-level enumerated unit is an instance of \code{cybed:OrganizingUnit} (subClassOf \code{skos:Concept}), the cross-framework -abstract that lets one SPARQL query reach all eleven frameworks' parent +abstract that lets one SPARQL query reach every framework's parent units uniformly. Workforce frameworks (NICE, DCWF, ENISA ECSF) where the unit is genuinely a work role or work profile additionally assert \code{cybed:Role} (itself \verb{subClassOf cybed:OrganizingUnit}); pass \code{is_role = TRUE} for those. Non-workforce frameworks (SFIA enumerates skills; -Cyber.org K-12, CSTA, CSEC2017, DigComp 2.2 enumerate other organizing +Cyber.org K-12, CSTA, CSEC2017, DigComp 3.0 enumerate other organizing units) assert \code{cybed:OrganizingUnit} only. Each unit also carries a per-framework subtype (e.g., \code{nice:WorkRole}, diff --git a/man/build_role_node.Rd b/man/build_role_node.Rd index 7ddccea..f836cb8 100644 --- a/man/build_role_node.Rd +++ b/man/build_role_node.Rd @@ -66,7 +66,7 @@ Named list (JSON-LD node). Convenience wrapper around \code{\link[=build_organizing_unit_node]{build_organizing_unit_node()}} for workforce frameworks (NICE, DCWF, ENISA ECSF). Asserts \code{cybed:Role} in addition to \code{cybed:OrganizingUnit} and the per-framework subtype. For non-workforce -frameworks (SFIA, Cyber.org K-12, CSTA, CSEC2017, DigComp 2.2), call +frameworks (SFIA, Cyber.org K-12, CSTA, CSEC2017, DigComp 3.0), call \code{\link[=build_organizing_unit_node]{build_organizing_unit_node()}} directly with \code{is_role = FALSE}. } \examples{ diff --git a/man/cybed_fetch.Rd b/man/cybed_fetch.Rd new file mode 100644 index 0000000..257a456 --- /dev/null +++ b/man/cybed_fetch.Rd @@ -0,0 +1,51 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cybed-fetch.R +\name{cybed_fetch} +\alias{cybed_fetch} +\title{Download and hash-verify per-framework release files into the user cache} +\usage{ +cybed_fetch(frameworks = NULL, version = NULL) +} +\arguments{ +\item{frameworks}{Character vector of framework slugs to fetch (as +carried by \link{framework_summary}\verb{$framework_slug}, e.g. \code{"nice-v2"}), or +\code{NULL} (the default) for every framework the release manifest ships.} + +\item{version}{Character scalar release version (e.g. \code{"1.0.0"}), or +\code{NULL} (the default) for the release location's \code{"latest"} alias.} +} +\value{ +Invisibly, a tibble with one row per fetched framework: columns +\code{framework_slug}, \code{path} (the cached file's local path), +\code{sha256_verified} (logical, always \code{TRUE} on return -- a mismatch +aborts instead of returning \code{FALSE}). +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +Downloads the release manifest and the named frameworks' \code{.nt.gz} files +from the configured release location (see the release URL resolution rules documented below), +verifies each file's SHA-256 against the hash the manifest declares, and +writes only into \code{tools::R_user_dir("cybedtools", "cache")} -- never any +other location on disk, per CRAN policy. A file whose downloaded bytes +do not match its manifest hash is deleted and the call aborts with class +\code{cybedtools_hash_mismatch} rather than leaving a corrupt cache entry. + +Already-cached files whose hash still matches the manifest are not +re-downloaded. +} +\examples{ +\donttest{ +# Requires network access (or a `cybedtools.release_url` override +# pointing at a local mock release for testing). +tryCatch( + cybed_fetch(frameworks = "nice-v2"), + cybedtools_download_failed = function(cnd) message("No network: ", conditionMessage(cnd)) +) +} +} +\seealso{ +Other data loading: +\code{\link[=load_graph]{load_graph()}} +} +\concept{data loading} diff --git a/man/cybedtools-package.Rd b/man/cybedtools-package.Rd index 9bb22bc..5939208 100644 --- a/man/cybedtools-package.Rd +++ b/man/cybedtools-package.Rd @@ -8,9 +8,9 @@ \description{ \if{html}{\figure{logo.png}{options: style="float: right; margin-left: 10px; margin-bottom: 10px;" alt="cybedtools logo" width="120"}} -Eleven cybersecurity workforce and learning frameworks (NICE, DCWF, SFIA, -ENISA ECSF, Cyber.org K-12, CSTA K-12 CS, ACM/IEEE CSEC2017, DigComp 2.2, -CyQUAL, CCSSF, OTCCF) expressed in a shared \verb{cybed:} semantic schema, +Cybersecurity workforce and learning frameworks (NICE, DCWF, SFIA, +ENISA ECSF, Cyber.org K-12, CSTA K-12 CS, ACM/IEEE CSEC2017, DigComp 3.0, +CyQUAL, CCSSF, OTCCF, and more) expressed in a shared \verb{cybed:} semantic schema, with R helpers that query across them as if they were one corpus. The package adds a comparison layer over existing frameworks rather than proposing a replacement. @@ -31,7 +31,7 @@ install. \item The \code{getting-started} vignette walks through loading a graph and running the domain helpers. \item The \code{cross-framework-analysis} vignette shows worked findings across the -full eleven-framework graph. +full corpus graph. \item The \code{adding-a-framework} vignette covers extending the package with a new framework. \item Function reference is grouped by family (JSON-LD construction, File I/O, diff --git a/man/element_framework_bindings.Rd b/man/element_framework_bindings.Rd index dd64bf5..cac746c 100644 --- a/man/element_framework_bindings.Rd +++ b/man/element_framework_bindings.Rd @@ -10,7 +10,8 @@ element_framework_bindings(rdf) \item{rdf}{An rdf object.} } \value{ -A tibble with columns \code{element}, \code{framework}, \code{framework_name}. +A tibble with columns \code{element}, \code{framework}, \code{framework_name}, +\code{framework_slug} (added v0.4.0, a stable join key). } \description{ \ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#stable}{\figure{lifecycle-stable.svg}{options: alt='[Stable]'}}}{\strong{[Stable]}} @@ -49,6 +50,8 @@ Other SPARQL helpers: \code{\link[=role_framework_bindings]{role_framework_bindings()}}, \code{\link[=sparql_pairs]{sparql_pairs()}}, \code{\link[=sparql_subjects]{sparql_subjects()}}, -\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}} +\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}}, +\code{\link[=unit_element_bindings]{unit_element_bindings()}}, +\code{\link[=unit_relation_bindings]{unit_relation_bindings()}} } \concept{SPARQL helpers} diff --git a/man/element_text.Rd b/man/element_text.Rd index 344004a..0c195b9 100644 --- a/man/element_text.Rd +++ b/man/element_text.Rd @@ -76,6 +76,8 @@ Other SPARQL helpers: \code{\link[=role_framework_bindings]{role_framework_bindings()}}, \code{\link[=sparql_pairs]{sparql_pairs()}}, \code{\link[=sparql_subjects]{sparql_subjects()}}, -\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}} +\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}}, +\code{\link[=unit_element_bindings]{unit_element_bindings()}}, +\code{\link[=unit_relation_bindings]{unit_relation_bindings()}} } \concept{SPARQL helpers} diff --git a/man/example_framework_bindings.Rd b/man/example_framework_bindings.Rd index 68eee9f..bbabe47 100644 --- a/man/example_framework_bindings.Rd +++ b/man/example_framework_bindings.Rd @@ -10,7 +10,8 @@ example_framework_bindings(rdf) \item{rdf}{An rdf object.} } \value{ -A tibble with columns \code{example}, \code{framework}, \code{framework_name}. +A tibble with columns \code{example}, \code{framework}, \code{framework_name}, +\code{framework_slug} (added v0.4.0, a stable join key). } \description{ \ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#stable}{\figure{lifecycle-stable.svg}{options: alt='[Stable]'}}}{\strong{[Stable]}} @@ -52,6 +53,8 @@ Other SPARQL helpers: \code{\link[=role_framework_bindings]{role_framework_bindings()}}, \code{\link[=sparql_pairs]{sparql_pairs()}}, \code{\link[=sparql_subjects]{sparql_subjects()}}, -\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}} +\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}}, +\code{\link[=unit_element_bindings]{unit_element_bindings()}}, +\code{\link[=unit_relation_bindings]{unit_relation_bindings()}} } \concept{SPARQL helpers} diff --git a/man/framework_licenses.Rd b/man/framework_licenses.Rd index 271c890..449b22a 100644 --- a/man/framework_licenses.Rd +++ b/man/framework_licenses.Rd @@ -5,7 +5,7 @@ \alias{framework_licenses} \title{Licence facts for the package code and every framework} \format{ -A tibble with 12 rows and 10 columns. +A tibble with 15 rows and 10 columns. \describe{ \item{layer}{Character. \code{"code"} for the package's own code, or \code{"framework"} for a framework's source content. Exactly one \code{"code"} diff --git a/man/framework_metadata.Rd b/man/framework_metadata.Rd index b6f7165..2a16973 100644 --- a/man/framework_metadata.Rd +++ b/man/framework_metadata.Rd @@ -11,8 +11,10 @@ framework_metadata(rdf) } \value{ A tibble with columns \code{framework}, \code{name}, \code{jurisdiction}, -\code{sector}, \code{specificity}. One row per framework typed as -\code{cybed:Framework}. +\code{sector}, \code{specificity}, \code{framework_slug}. One row per framework typed +as \code{cybed:Framework}. \code{framework_slug} is added (v0.4.0) as a stable +join key across frameworks; every existing column is unchanged and +row order is unchanged. } \description{ \ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#stable}{\figure{lifecycle-stable.svg}{options: alt='[Stable]'}}}{\strong{[Stable]}} @@ -46,6 +48,8 @@ Other SPARQL helpers: \code{\link[=role_framework_bindings]{role_framework_bindings()}}, \code{\link[=sparql_pairs]{sparql_pairs()}}, \code{\link[=sparql_subjects]{sparql_subjects()}}, -\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}} +\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}}, +\code{\link[=unit_element_bindings]{unit_element_bindings()}}, +\code{\link[=unit_relation_bindings]{unit_relation_bindings()}} } \concept{SPARQL helpers} diff --git a/man/framework_similarity.Rd b/man/framework_similarity.Rd new file mode 100644 index 0000000..e14a438 --- /dev/null +++ b/man/framework_similarity.Rd @@ -0,0 +1,69 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/similarity-helpers.R +\name{framework_similarity} +\alias{framework_similarity} +\title{Cross-framework unit-text similarity, top-n matches per unit} +\usage{ +framework_similarity(rdf, from, to, n = 5) +} +\arguments{ +\item{rdf}{An rdf object.} + +\item{from, to}{Character scalars, the \code{framework_slug} values (from +\code{\link[=organizing_unit_framework_bindings]{organizing_unit_framework_bindings()}}) whose organizing units are +compared. May be identical, to find near-duplicate units within one +framework.} + +\item{n}{Integer, matches to keep per from-unit (default \code{5}).} +} +\value{ +A tibble with one row per (from unit, match): columns +\code{from_unit}, \code{to_unit} (both full IRIs), \code{score} (numeric in \verb{[0, 1]}), +\code{strength} (\code{"strong"}/\code{"moderate"}/\code{"weak"}/\code{"none"}), and \code{rank} +(integer, \verb{1..k}, dense per \code{from_unit}). A \code{from}-side unit with zero +candidates on the \code{to} side contributes no rows. A \code{from}-side unit +whose only candidates all score 0 still appears, ranked, with +\code{strength == "none"}. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +Reproduces, as the one public entry point, the "full-document" Jaccard +similarity computed script-locally by the concordance data-prep scripts +(\verb{concordance/_data-prep-*.R}, e.g. \verb{_data-prep-nice-ecsf-alignment.R}'s +\code{build_full_doc()}): each organizing unit's comparison text is its own +\code{schema:name}, its own \code{cybed:elementText} when it carries one directly +(true for pedagogy units where the unit and its top-level statement share +one IRI, e.g. Cyber.org/CSTA cells), and the concatenated +\code{cybed:elementText} of every element reachable via +\code{\link[=unit_element_bindings]{unit_element_bindings()}} (true for workforce units, whose task/ +knowledge/skill statements are separate elements linked by +\code{cybed:hasElement}). This single text-assembly rule is what lets one +function serve both structural shapes. Every (from-unit, to-unit) pair is +then scored with \code{jaccard()}, the top \code{n} matches per from-unit are kept +via \code{top_n_matches()}, and each score's \code{similarity_strength()} is +attached. The tokenizer, the Jaccard set-similarity, the stopword list +and the ranking tie-break are internal (\verb{@noRd}) and may change without +notice; only this function's signature and output shape are the +contract. + +\code{from} and \code{to} select organizing units by \code{framework_slug} +(\code{\link[=organizing_unit_framework_bindings]{organizing_unit_framework_bindings()}}\verb{$framework_slug}), matching how +the concordance scripts partition units before scoring +(\code{framework_slug == "cyberorg-k12"} vs \code{"csta-2017"}, for example). A +unit with no name, no own text, and no child element text at all +contributes no rows on either side (empty document, nothing to compare). +} +\examples{ +rdf <- make_demo_graph() +# make_demo_graph()'s two frameworks carry no cybed:elementText or +# cybed:hasElement text, so this is a zero-row tibble with the columns +# from_unit/to_unit/score/strength/rank. +framework_similarity(rdf, from = "demo-fw-a", to = "demo-fw-b") + +\dontrun{ +rdf <- load_combined_ntriples_graph() +framework_similarity(rdf, from = "cyberorg-k12", to = "csta-2017", n = 3) +} +} +\concept{similarity helpers} diff --git a/man/framework_summary.Rd b/man/framework_summary.Rd index f8c581f..55fb025 100644 --- a/man/framework_summary.Rd +++ b/man/framework_summary.Rd @@ -3,9 +3,9 @@ \docType{data} \name{framework_summary} \alias{framework_summary} -\title{Eleven-framework summary tibble} +\title{Framework summary tibble} \format{ -A tibble with 11 rows and 16 columns. +A tibble with 14 rows and 16 columns. \describe{ \item{framework_slug}{Character. Stable slug used as the URI tail (e.g., \code{"nice-v2"}, \code{"sfia-9"}, \code{"otccf-v1.1"}). Unique.} @@ -16,15 +16,16 @@ carries the full published title.} of \code{"workforce"} or \code{"pedagogy"}. Independent of the structural \code{cybed:Role} vs \code{cybed:OrganizingUnit} distinction.} \item{jurisdiction}{Character. One of \code{"US"}, \code{"EU"}, \code{"CZ"}, \code{"CA"}, -\code{"SG"}, or \code{"global"}.} +\code{"SG"}, \code{"SA"}, \code{"UK"}, or \code{"global"}.} \item{organizing_unit_count}{Integer. Distinct subjects typed \code{cybed:OrganizingUnit} and bound to the framework via \code{cybed:partOf}. A mixed denominator where a framework publishes more than one kind of unit, as described above.} \item{role_count}{Integer. Distinct subjects typed \code{cybed:Role} and bound to the framework via \code{cybed:partOf}. A subset of -\code{organizing_unit_count}. \code{NA} for the five frameworks that assert no -roles at all (SFIA, Cyber.org K-12, CSTA, CSEC2017, DigComp 2.2). +\code{organizing_unit_count}. \code{NA} for the frameworks that assert no +roles at all (SFIA, Cyber.org K-12, CSTA 2017, CSTA 2026, CSEC2017, +DigComp 3.0, CyBOK). \code{NA} rather than zero, because "this framework does not use the role construct" is a different statement from "this framework has zero roles".} @@ -34,21 +35,23 @@ Equals \code{element_count_with_examples} less \code{subpoint_count} less both enumeration splitting and pedagogical scaffolding.} \item{subpoint_count}{Integer. Distinct \code{cybed:Subpoint} instances: enumeration-list splits parsed out of a parent's text at assembly -time, plus, for CCSSF only, sub-bullets the source itself prints -beneath a parent bullet. Zero where the parser is disabled (OTCCF) -or finds nothing.} -\item{example_count}{Integer. Distinct \code{cybed:Example} instances -(Clarification-statement scaffolding). Non-zero for Cyber.org K-12 -and CSTA only.} +time, plus sub-points the source itself prints or draws beneath a +parent: CCSSF's sub-bullets and CyBOK's Indicative Material. Zero where +the parser is disabled and the source has none (OTCCF, csta-2026, +DigComp 3.0, SCyWF) or where the parser finds nothing.} +\item{example_count}{Integer. Distinct \code{cybed:Example} instances: +Clarification-statement scaffolding for Cyber.org K-12 and CSTA 2017, +and published implementation examples for CSTA 2026. Zero everywhere +else.} \item{element_count_with_examples}{Integer. Distinct elements typed \code{cybed:RoleElement} and bound to the framework: parents plus Subpoints plus Examples.} \item{unit_relation_count}{Integer. Distinct \code{cybed:UnitRelation} nodes bound to the framework: qualified unit-to-unit statements, each carrying its published proficiency level. Nonzero for OTCCF only -(310: 190 role-to-TSC plus 120 role-to-Critical-Core-Skill), zero -everywhere else, since no other framework in the corpus publishes a -skills map of this shape.} +(role-to-Technical-Skill-Competency plus role-to-Critical-Core-Skill +relations), zero everywhere else, since no other framework in the +corpus publishes a skills map of this shape.} \item{elements_per_organizing_unit_strict}{Numeric. \code{element_count_strict / organizing_unit_count}, rounded to one decimal. The supplementary density figure, reported in the @@ -57,7 +60,8 @@ cross-framework-analysis vignette.} \code{element_count_with_examples / organizing_unit_count}, rounded to one decimal. The headline density figure used in the README. The vignette shows it alongside the strict column to make visible how Examples -inflate Cyber.org K-12 and CSTA's apparent specification density.} +inflate the apparent specification density of Cyber.org K-12 and both +CSTA editions.} \item{elements_per_role_strict}{Numeric. The count of DISTINCT parent elements reachable by \code{cybed:hasElement} from a role-typed organizing unit, divided by \code{role_count}, rounded to one decimal. \code{NA} where @@ -79,7 +83,7 @@ it. Do not treat this column as a statement of terms; it is a label.} } } \source{ -Computed from the eleven-framework combined graph produced by +Computed from the combined graph produced by \code{scripts/025-export-ntriples.R}. See \code{data-raw/build-framework-summary.R}. } @@ -94,17 +98,42 @@ via \code{data-raw/build-framework-summary.R}. Display name, framework type originate outside the JSON-LD graph. } \details{ -Three frameworks were added in v0.3.0 on steward terms: CyQUAL +Several frameworks were added in v0.3.0 on steward terms: CyQUAL (Czech Republic, open data, attribution to CyQUAL and Masaryk -University), CCSSF (Canada, Government of Canada copyright, -referenced as the Canadian Centre for Cyber Security asked), and OTCCF +University), CCSSF (Canada, Government of Canada copyright, used with +permission of the Canadian Centre for Cyber Security), and OTCCF (Singapore, Cyber Security Agency of Singapore copyright, permission for non-commercial academic and research use). The build script fails loudly in both directions if the graph and the curated display table disagree about which frameworks exist. -Statement codes are unique only within a framework. NICE and CCSSF both -print codes in the T0516 shape, and they denote different statements. +The 2026 CSTA PK-12 Computer Science Standards (\code{csta-2026}) were added +after v0.3.0 as a framework of their own alongside the 2017 edition +(\code{csta-2017}), whose row is unchanged. Its organizing units are level x +concept groups for the foundational tier and tier x specialty area groups +for the specialty tier (see \code{organizing_unit_count} for the current +total). Its Examples are CSTA's published implementation examples. + +The Saudi Cybersecurity Workforce Framework (\code{scywf-1.5}, SCyWF - 1.5 : +2026) was added after v0.3.0 by written permission of the National +Cybersecurity Authority (NCA), which requires its content to be carried +verbatim. Its organizing units span job roles, competency areas, +specialty areas and categories (see \code{organizing_unit_count} and +\code{role_count} for current totals). Only the job roles assert +\code{cybed:Role}. The links from a specialty area to its category and from a +role to its specialty area are cybedtools-derived, not NCA content. + +The Cyber Security Body of Knowledge (\code{cybok-v1.1.0}, CyBOK v1.1.0, July +2021) was added after v0.3.0 under the Open Government Licence v3.0. Its +organizing units are its Knowledge Areas, which assert no \code{cybed:Role}. +Its parent elements are the Topics of each KA's Knowledge Tree, and the +Indicative Material the trees draw under each Topic is carried as +\code{cybed:Subpoint}. Those Subpoints are source-drawn, not parser output. + +Statement codes are unique only within a framework. NICE, CCSSF and +SCyWF all print codes in the T0516 shape, and they denote different +statements. SCyWF reuses a block of NICE v2.2.0 codes and prints +different text under every one of them. Never join two frameworks on a bare statement code. Join on the full IRI, or carry a framework column alongside the code. } @@ -114,19 +143,20 @@ or carry a framework column alongside the code. the framework: parents, plus \code{cybed:Subpoint} children, plus \code{cybed:Example} children. \code{element_count_strict} counts parents only, that is with-examples less both the Subpoint and the Example populations. -Only DCWF, DigComp 2.2 and OTCCF have zero of both children, so strict -equals with-examples there. +OTCCF, csta-2026, DigComp 3.0 and SCyWF have zero Subpoints (parser +disabled for all four on fidelity grounds. See +docs/framework-invariants.yml). CyBOK runs with the parser disabled too, +and its Subpoints are the Indicative Material its Knowledge Trees draw. Only OTCCF and SCyWF have zero of both +children, so strict equals with-examples there. DigComp 3.0 still has a nonzero \code{example_count} with the parser +off: its Learning Outcomes attach as \code{cybed:Example} children of each +Competence unit, so strict is less than with-examples there. -\strong{Corrected 2026-08-14}: \code{element_count_strict} previously subtracted -only \code{example_count}, so any framework with nonzero Subpoints carried an -inflated "strict" value that silently included non-parent content -(NICE +4, SFIA +158, ECSF +16, CSTA +20, CSEC2017 +2). That is what -produced the NICE 2,115-vs-NIST's-2,111 discrepancy caught in the -Concordance manuscript audit. The README headline "density spread" -finding uses the with-examples count, which counts what each framework -puts in front of a teacher, trainee, or curriculum designer. The strict -count is the supplementary figure, reported in the -cross-framework-analysis vignette. +\code{element_count_strict} excludes both \code{cybed:Subpoint} and \code{cybed:Example} +content; see NEWS.md for the 2026-08-14 correction to this definition. +The README headline "density spread" finding uses the with-examples +count, which counts what each framework puts in front of a teacher, +trainee, or curriculum designer. The strict count is the supplementary +figure, reported in the cross-framework-analysis vignette. The related count in \code{docs/framework-invariants.yml}, \code{total_elements_with_subpoints}, is parents plus Subpoints excluding @@ -136,12 +166,14 @@ Examples. This tibble's \code{element_count_strict} is parents only. \section{Units, roles, and density}{ \code{organizing_unit_count} counts every \code{cybed:OrganizingUnit}, which for -several frameworks mixes more than one kind of unit: NICE contributes 42 -work roles plus 11 competency areas, CyQUAL 102 work roles plus 59 -competencies, OTCCF 15 job roles plus 30 Technical Skills and -Competencies plus 16 Critical Core Skills. A density taken over that -mixed denominator is not like-for-like against a framework whose units -are all roles, such as DCWF's 74. The \code{role_count} and +several frameworks mixes more than one kind of unit: NICE contributes +work roles plus competency areas, CyQUAL work roles plus competencies, +OTCCF job roles plus Technical Skills and Competencies plus Critical +Core Skills, SCyWF job roles plus competency areas, specialty areas and +categories (see \code{organizing_unit_count} and \code{role_count} for the +current per-framework split). A density taken over that mixed +denominator is not like-for-like against a framework whose units are +all roles, such as DCWF. The \code{role_count} and \verb{elements_per_role_*} columns added in v0.3.0 give the role-only cut. The \verb{elements_per_organizing_unit_*} columns keep the definition they have always had, because they are published figures. diff --git a/man/load_graph.Rd b/man/load_graph.Rd new file mode 100644 index 0000000..93cd71b --- /dev/null +++ b/man/load_graph.Rd @@ -0,0 +1,43 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/cybed-fetch.R +\name{load_graph} +\alias{load_graph} +\title{Load a graph from the cached release files, fetching first if needed} +\usage{ +load_graph(frameworks = NULL, version = NULL) +} +\arguments{ +\item{frameworks}{Character vector of framework slugs to fetch (as +carried by \link{framework_summary}\verb{$framework_slug}, e.g. \code{"nice-v2"}), or +\code{NULL} (the default) for every framework the release manifest ships.} + +\item{version}{Character scalar release version (e.g. \code{"1.0.0"}), or +\code{NULL} (the default) for the release location's \code{"latest"} alias.} +} +\value{ +An rdf object (\code{\link[rdflib:rdf]{rdflib::rdf()}}) with every requested framework's +triples loaded. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +Ensures the requested frameworks are cached (calling \code{\link[=cybed_fetch]{cybed_fetch()}} +internally, which is a no-op for files already cached with a verified +hash) and parses them into one shared rdf object. +} +\examples{ +\donttest{ +tryCatch( + { + rdf <- load_graph(frameworks = "nice-v2") + framework_metadata(rdf) + }, + cybedtools_download_failed = function(cnd) message("No network: ", conditionMessage(cnd)) +) +} +} +\seealso{ +Other data loading: +\code{\link[=cybed_fetch]{cybed_fetch()}} +} +\concept{data loading} diff --git a/man/load_single_framework_graph.Rd b/man/load_single_framework_graph.Rd index 5f2d5bb..cb61491 100644 --- a/man/load_single_framework_graph.Rd +++ b/man/load_single_framework_graph.Rd @@ -8,8 +8,8 @@ load_single_framework_graph(framework_slug, jsonld_dir = NULL) } \arguments{ \item{framework_slug}{Character, one of \code{"nice"}, \code{"sfia"}, \code{"dcwf"}, -\code{"ecsf"}, \code{"cyberorg-k12"}, \code{"csta"}, \code{"csec2017"}, \code{"digcomp"}, or -\code{"cyqual"}, \code{"ccssf"}, or \code{"otccf"}.} +\code{"ecsf"}, \code{"cyberorg-k12"}, \code{"csta"}, \code{"csta-2026"}, \code{"csec2017"}, +\code{"digcomp"}, \code{"cyqual"}, \code{"ccssf"}, \code{"otccf"}, \code{"scywf"}, or \code{"cybok"}.} \item{jsonld_dir}{Character path to the directory containing per-framework JSON-LD files. Defaults to \verb{data/processed/jsonld/}.} diff --git a/man/load_unified_rdf_graph.Rd b/man/load_unified_rdf_graph.Rd index a4a0d5d..b0323de 100644 --- a/man/load_unified_rdf_graph.Rd +++ b/man/load_unified_rdf_graph.Rd @@ -5,8 +5,8 @@ \title{Load every framework's JSON-LD into a unified rdflib graph} \usage{ load_unified_rdf_graph( - framework_slugs = c("nice", "sfia", "dcwf", "ecsf", "cyberorg-k12", "csta", "csec2017", - "digcomp", "cyqual", "ccssf", "otccf"), + framework_slugs = c("nice", "sfia", "dcwf", "ecsf", "cyberorg-k12", "csta", + "csta-2026", "csec2017", "digcomp", "cyqual", "ccssf", "otccf", "scywf", "cybok"), jsonld_dir = NULL ) } diff --git a/man/make_demo_graph.Rd b/man/make_demo_graph.Rd index 82e4163..c6d37e8 100644 --- a/man/make_demo_graph.Rd +++ b/man/make_demo_graph.Rd @@ -21,7 +21,7 @@ workforce-shaped: its 2 organizing units are typed \code{cybed:Role} and \code{cybed:OrganizingUnit}, mirroring NICE / DCWF / ENISA ECSF. \item \strong{Framework B} (EU, general, general-IT) is non-workforce-shaped: its 1 organizing unit is typed \code{cybed:OrganizingUnit} only, -mirroring SFIA / Cyber.org K-12 / CSTA / CSEC2017 / DigComp 2.2. +mirroring SFIA / Cyber.org K-12 / CSTA / CSEC2017 / DigComp 3.0. } The graph also includes 5 atomic elements (typed \code{cybed:RoleElement}), diff --git a/man/organizing_unit_framework_bindings.Rd b/man/organizing_unit_framework_bindings.Rd index 4e8def9..b2f60be 100644 --- a/man/organizing_unit_framework_bindings.Rd +++ b/man/organizing_unit_framework_bindings.Rd @@ -11,13 +11,13 @@ organizing_unit_framework_bindings(rdf) } \value{ A tibble with columns \code{unit}, \code{unit_name}, \code{framework}, -\code{framework_name}. +\code{framework_name}, \code{framework_slug} (added v0.4.0, a stable join key). } \description{ \ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#stable}{\figure{lifecycle-stable.svg}{options: alt='[Stable]'}}}{\strong{[Stable]}} -One row per (organizing unit, framework) pair across all eleven -frameworks. Queries on the cross-framework abstract type +One row per (organizing unit, framework) pair across every framework in +the corpus. Queries on the cross-framework abstract type \code{cybed:OrganizingUnit}, which every framework's top-level enumerated unit asserts (work roles, work profiles, skills, grade-band x sub-concept cells, level x concept cells, Knowledge Areas, competence areas). Use this @@ -54,6 +54,8 @@ Other SPARQL helpers: \code{\link[=role_framework_bindings]{role_framework_bindings()}}, \code{\link[=sparql_pairs]{sparql_pairs()}}, \code{\link[=sparql_subjects]{sparql_subjects()}}, -\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}} +\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}}, +\code{\link[=unit_element_bindings]{unit_element_bindings()}}, +\code{\link[=unit_relation_bindings]{unit_relation_bindings()}} } \concept{SPARQL helpers} diff --git a/man/role_element_bindings.Rd b/man/role_element_bindings.Rd index f30728e..f32b20e 100644 --- a/man/role_element_bindings.Rd +++ b/man/role_element_bindings.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/sparql-helpers.R \name{role_element_bindings} \alias{role_element_bindings} -\title{Domain helper: organizing-unit-to-element bindings} +\title{Deprecated alias for \code{\link[=unit_element_bindings]{unit_element_bindings()}}} \usage{ role_element_bindings(rdf) } @@ -10,25 +10,16 @@ role_element_bindings(rdf) \item{rdf}{An rdf object.} } \value{ -A tibble with columns \code{role}, \code{element}. +See \code{\link[=unit_element_bindings]{unit_element_bindings()}}. } \description{ -\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#stable}{\figure{lifecycle-stable.svg}{options: alt='[Stable]'}}}{\strong{[Stable]}} +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#deprecated}{\figure{lifecycle-deprecated.svg}{options: alt='[Deprecated]'}}}{\strong{[Deprecated]}} -One row per (parent, element) pair derived from \code{cybed:hasElement} -triples. Despite the "role" naming, the \code{role} column is NOT restricted -to \code{cybed:Role} subjects -- \code{cybed:hasElement} is the universal parent- -child link used across all eleven frameworks, so this returns element -bindings for every \code{cybed:OrganizingUnit} (SFIA skills, Cyber.org K-12 -and CSTA standards, etc.), not just NICE/DCWF/ECSF work roles. Confirmed -2026-08-14 stress test: of 428 distinct values in the \code{role} column, -only 127 (30\%) are actual \code{cybed:Role} subjects. Low practical risk when -immediately joined against \code{\link[=role_framework_bindings]{role_framework_bindings()}} or -\code{\link[=organizing_unit_framework_bindings]{organizing_unit_framework_bindings()}} (the mismatches drop out), but a -standalone aggregate over this tibble's \code{role} column (e.g. "average -elements per role") will silently include non-role parents. Filter to -\code{cybed:Role} first via \code{\link[=role_framework_bindings]{role_framework_bindings()}} if that distinction -matters for your analysis. +\code{role_element_bindings()} is deprecated as of cybedtools 0.4.0 in favor of +\code{\link[=unit_element_bindings]{unit_element_bindings()}}, which returns the identical result (all +organizing units, not just \code{cybed:Role} subjects) under a name that +doesn't overstate the role restriction. This alias will be removed in a +future minor version; update call sites to \code{unit_element_bindings()}. } \examples{ \dontrun{ @@ -46,6 +37,8 @@ Other SPARQL helpers: \code{\link[=role_framework_bindings]{role_framework_bindings()}}, \code{\link[=sparql_pairs]{sparql_pairs()}}, \code{\link[=sparql_subjects]{sparql_subjects()}}, -\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}} +\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}}, +\code{\link[=unit_element_bindings]{unit_element_bindings()}}, +\code{\link[=unit_relation_bindings]{unit_relation_bindings()}} } \concept{SPARQL helpers} diff --git a/man/role_framework_bindings.Rd b/man/role_framework_bindings.Rd index e55de85..ded3266 100644 --- a/man/role_framework_bindings.Rd +++ b/man/role_framework_bindings.Rd @@ -11,7 +11,7 @@ role_framework_bindings(rdf) } \value{ A tibble with columns \code{role}, \code{role_name}, \code{framework}, -\code{framework_name}. +\code{framework_name}, \code{framework_slug} (added v0.4.0, a stable join key). } \description{ \ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#stable}{\figure{lifecycle-stable.svg}{options: alt='[Stable]'}}}{\strong{[Stable]}} @@ -25,7 +25,7 @@ K-12 grade-band x sub-concept cells, CSTA level x concept cells, CSEC2017 Knowledge Areas, and DigComp competence areas are not roles and are not returned by this helper. Use \code{\link[=organizing_unit_framework_bindings]{organizing_unit_framework_bindings()}} for the cross-framework "top-level enumerated unit" cut that includes -all eleven frameworks. +every framework in the corpus. Roles without a \code{cybed:partOf} triple, or whose partOf target is not typed \code{cybed:Framework}, are excluded. @@ -54,6 +54,8 @@ Other SPARQL helpers: \code{\link[=role_element_bindings]{role_element_bindings()}}, \code{\link[=sparql_pairs]{sparql_pairs()}}, \code{\link[=sparql_subjects]{sparql_subjects()}}, -\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}} +\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}}, +\code{\link[=unit_element_bindings]{unit_element_bindings()}}, +\code{\link[=unit_relation_bindings]{unit_relation_bindings()}} } \concept{SPARQL helpers} diff --git a/man/sparql_pairs.Rd b/man/sparql_pairs.Rd index 7df3398..1050af9 100644 --- a/man/sparql_pairs.Rd +++ b/man/sparql_pairs.Rd @@ -42,6 +42,8 @@ Other SPARQL helpers: \code{\link[=role_element_bindings]{role_element_bindings()}}, \code{\link[=role_framework_bindings]{role_framework_bindings()}}, \code{\link[=sparql_subjects]{sparql_subjects()}}, -\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}} +\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}}, +\code{\link[=unit_element_bindings]{unit_element_bindings()}}, +\code{\link[=unit_relation_bindings]{unit_relation_bindings()}} } \concept{SPARQL helpers} diff --git a/man/sparql_subjects.Rd b/man/sparql_subjects.Rd index 4645cac..6d4853b 100644 --- a/man/sparql_subjects.Rd +++ b/man/sparql_subjects.Rd @@ -39,6 +39,8 @@ Other SPARQL helpers: \code{\link[=role_element_bindings]{role_element_bindings()}}, \code{\link[=role_framework_bindings]{role_framework_bindings()}}, \code{\link[=sparql_pairs]{sparql_pairs()}}, -\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}} +\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}}, +\code{\link[=unit_element_bindings]{unit_element_bindings()}}, +\code{\link[=unit_relation_bindings]{unit_relation_bindings()}} } \concept{SPARQL helpers} diff --git a/man/subpoint_framework_bindings.Rd b/man/subpoint_framework_bindings.Rd index 5331f40..2a4dc70 100644 --- a/man/subpoint_framework_bindings.Rd +++ b/man/subpoint_framework_bindings.Rd @@ -10,7 +10,8 @@ subpoint_framework_bindings(rdf) \item{rdf}{An rdf object.} } \value{ -A tibble with columns \code{subpoint}, \code{framework}, \code{framework_name}. +A tibble with columns \code{subpoint}, \code{framework}, \code{framework_name}, +\code{framework_slug} (added v0.4.0, a stable join key). } \description{ \ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#stable}{\figure{lifecycle-stable.svg}{options: alt='[Stable]'}}}{\strong{[Stable]}} @@ -19,7 +20,7 @@ One row per (subpoint, framework) pair where the subpoint is typed \code{cybed:Subpoint} (the generic enumeration-list-splitting subtype -- "such as X, Y, and Z" / "including A and B" -- parsed out of a single native unit's text at JSON-LD assembly time, applied uniformly across -all eleven frameworks) and its \code{partOf} target is typed +the corpus) and its \code{partOf} target is typed \code{cybed:Framework}. Subpoints without a valid framework partOf are excluded. @@ -57,6 +58,8 @@ Other SPARQL helpers: \code{\link[=role_element_bindings]{role_element_bindings()}}, \code{\link[=role_framework_bindings]{role_framework_bindings()}}, \code{\link[=sparql_pairs]{sparql_pairs()}}, -\code{\link[=sparql_subjects]{sparql_subjects()}} +\code{\link[=sparql_subjects]{sparql_subjects()}}, +\code{\link[=unit_element_bindings]{unit_element_bindings()}}, +\code{\link[=unit_relation_bindings]{unit_relation_bindings()}} } \concept{SPARQL helpers} diff --git a/man/unit_element_bindings.Rd b/man/unit_element_bindings.Rd new file mode 100644 index 0000000..221323a --- /dev/null +++ b/man/unit_element_bindings.Rd @@ -0,0 +1,60 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/sparql-helpers.R +\name{unit_element_bindings} +\alias{unit_element_bindings} +\title{Domain helper: organizing-unit-to-element bindings} +\usage{ +unit_element_bindings(rdf) +} +\arguments{ +\item{rdf}{An rdf object.} +} +\value{ +A tibble with columns \code{role}, \code{element}, \code{framework_slug}. +\code{framework_slug} (added v0.4.0) is the slug of the \code{role} (organizing +unit) subject's own framework, taken from +\code{\link[=organizing_unit_framework_bindings]{organizing_unit_framework_bindings()}}; a \code{role} with no valid +\code{cybed:partOf} to a \code{cybed:Framework} carries \code{NA} here rather than +dropping the row, so this helper's row count is unchanged from before +v0.4.0. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#stable}{\figure{lifecycle-stable.svg}{options: alt='[Stable]'}}}{\strong{[Stable]}} + +One row per (parent, element) pair derived from \code{cybed:hasElement} +triples. Despite the "role" naming, the \code{role} column is NOT restricted +to \code{cybed:Role} subjects -- \code{cybed:hasElement} is the universal parent- +child link used across every framework in the corpus, so this returns +element bindings for every \code{cybed:OrganizingUnit} (SFIA skills, +Cyber.org K-12 and CSTA standards, etc.), not just NICE/DCWF/ECSF work +roles. Confirmed by a 2026-08-14 stress test: most distinct values in +the \code{role} column are not actual \code{cybed:Role} subjects. Low practical +risk when +immediately joined against \code{\link[=role_framework_bindings]{role_framework_bindings()}} or +\code{\link[=organizing_unit_framework_bindings]{organizing_unit_framework_bindings()}} (the mismatches drop out), but a +standalone aggregate over this tibble's \code{role} column (e.g. "average +elements per role") will silently include non-role parents. Filter to +\code{cybed:Role} first via \code{\link[=role_framework_bindings]{role_framework_bindings()}} if that distinction +matters for your analysis. +} +\examples{ +\dontrun{ +rdf <- load_combined_ntriples_graph() +unit_element_bindings(rdf) +} +} +\seealso{ +Other SPARQL helpers: +\code{\link[=element_framework_bindings]{element_framework_bindings()}}, +\code{\link[=element_text]{element_text()}}, +\code{\link[=example_framework_bindings]{example_framework_bindings()}}, +\code{\link[=framework_metadata]{framework_metadata()}}, +\code{\link[=organizing_unit_framework_bindings]{organizing_unit_framework_bindings()}}, +\code{\link[=role_element_bindings]{role_element_bindings()}}, +\code{\link[=role_framework_bindings]{role_framework_bindings()}}, +\code{\link[=sparql_pairs]{sparql_pairs()}}, +\code{\link[=sparql_subjects]{sparql_subjects()}}, +\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}}, +\code{\link[=unit_relation_bindings]{unit_relation_bindings()}} +} +\concept{SPARQL helpers} diff --git a/man/unit_relation_bindings.Rd b/man/unit_relation_bindings.Rd new file mode 100644 index 0000000..30066e9 --- /dev/null +++ b/man/unit_relation_bindings.Rd @@ -0,0 +1,45 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/sparql-helpers.R +\name{unit_relation_bindings} +\alias{unit_relation_bindings} +\title{Domain helper: unit-to-unit relation bindings} +\usage{ +unit_relation_bindings(rdf) +} +\arguments{ +\item{rdf}{An rdf object.} +} +\value{ +A tibble with columns \code{from_unit}, \code{relation}, \code{to_unit}, +\code{framework_slug}. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +One row per \code{cybed:UnitRelation} node (\code{\link[=build_unit_relation_node]{build_unit_relation_node()}}), +giving the related pair of organizing units plus the relation's own +framework attribution. \code{relation} is \code{NA} for a plain (unlabeled) +relation; \code{framework_slug} is \code{NA} when the relation node carries no +\code{cybed:partOf}, or one whose target is not typed \code{cybed:Framework}. +} +\examples{ +\dontrun{ +rdf <- load_combined_ntriples_graph() +unit_relation_bindings(rdf) +} +} +\seealso{ +Other SPARQL helpers: +\code{\link[=element_framework_bindings]{element_framework_bindings()}}, +\code{\link[=element_text]{element_text()}}, +\code{\link[=example_framework_bindings]{example_framework_bindings()}}, +\code{\link[=framework_metadata]{framework_metadata()}}, +\code{\link[=organizing_unit_framework_bindings]{organizing_unit_framework_bindings()}}, +\code{\link[=role_element_bindings]{role_element_bindings()}}, +\code{\link[=role_framework_bindings]{role_framework_bindings()}}, +\code{\link[=sparql_pairs]{sparql_pairs()}}, +\code{\link[=sparql_subjects]{sparql_subjects()}}, +\code{\link[=subpoint_framework_bindings]{subpoint_framework_bindings()}}, +\code{\link[=unit_element_bindings]{unit_element_bindings()}} +} +\concept{SPARQL helpers} diff --git a/python/README.md b/python/README.md index 18c0318..23559bd 100644 --- a/python/README.md +++ b/python/README.md @@ -4,15 +4,11 @@ cybedtools harmonizes cybersecurity workforce competency frameworks and cybersec ## Status of this release -This is a placeholder. It installs, it imports, and it reports its version. It does not query anything yet. The release exists to reserve the `cybedtools` name on PyPI while the Python interface is built. +This Python package mirrors the R package's public API: data loading, reference tables, query helpers (`*_bindings()`, `framework_metadata()`), `framework_similarity()`, and `cybed_fetch()`/`load_graph()` for downloading and verifying release graphs. -## Use the R package today +## How the two packages relate -The mature interface is the R package, which builds the graph and ships the query helpers. It lives in the same repository: https://github.com/ryanstraight/cybedtools - -## How the two packages will relate - -The Python package will read the same published per-framework graph files that the R package produces, so both languages work from one set of data rather than two parallel builds. Function names and the column names of returned tables will match the R package, so one description in a paper covers both implementations. +The Python package reads the same published per-framework graph files that the R package produces, so both languages work from one set of data rather than two parallel builds. Function names and the column names of returned tables match the R package, so one description in a paper covers both implementations. The R package remains the reference implementation and lives in the same repository: https://github.com/ryanstraight/cybedtools ## Licensing diff --git a/python/pyproject.toml b/python/pyproject.toml index 6bfcbf0..247013c 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,29 +4,36 @@ build-backend = "hatchling.build" [project] name = "cybedtools" -version = "0.0.1" -description = "Python interface to the cybedtools cross-framework cybersecurity workforce and learning frameworks graph. This first release reserves the name while the package is built." +version = "0.1.0" +description = "Python interface to the cybedtools cross-framework cybersecurity workforce and learning frameworks graph, mirroring the R package's API: data loading, reference tables, query helpers, similarity, and release fetching." readme = "README.md" license = "MIT" license-files = ["LICENSE"] -requires-python = ">=3.12" +requires-python = ">=3.10" authors = [{ name = "Ryan Straight", email = "ryanstraight@arizona.edu" }] keywords = ["cybersecurity", "workforce", "frameworks", "rdf", "sparql"] classifiers = [ - "Development Status :: 1 - Planning", + "Development Status :: 2 - Pre-Alpha", "Intended Audience :: Science/Research", "Intended Audience :: Education", "Topic :: Security", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", "Typing :: Typed", ] -dependencies = [] +dependencies = [ + "rdflib>=7.0", + "pandas>=2.0", + "pyarrow>=14.0", + "platformdirs>=4.0", +] [project.optional-dependencies] -dev = ["pytest>=8", "build>=1.2"] +oxigraph = ["pyoxigraph>=0.4"] +dev = ["pytest>=8", "build>=1.2", "tomli>=2; python_version < '3.11'"] [project.urls] Homepage = "https://ryanstraight.github.io/cybedtools/" @@ -37,4 +44,4 @@ Issues = "https://github.com/ryanstraight/cybedtools/issues" packages = ["src/cybedtools"] [tool.hatch.build.targets.sdist] -include = ["src", "tests", "README.md", "LICENSE", "pyproject.toml"] +include = ["src", "tests", "tools", "README.md", "LICENSE", "pyproject.toml"] diff --git a/python/src/cybedtools/__init__.py b/python/src/cybedtools/__init__.py index a116d08..c0a62de 100644 --- a/python/src/cybedtools/__init__.py +++ b/python/src/cybedtools/__init__.py @@ -1,8 +1,11 @@ """Python interface to the cybedtools cross-framework cybersecurity workforce and learning frameworks graph. -This release is a placeholder that reserves the project name on PyPI. It exposes -no query functions yet. The R package at https://github.com/ryanstraight/cybedtools -is the working interface today. +This release builds the foundation of the Python port: data loading +(:func:`cybed_fetch`, :func:`load_graph`), the shipped reference tables +(:func:`framework_summary`, :func:`framework_licenses`, :func:`cybed_license`), +cross-framework similarity (:func:`framework_similarity`), and the query +helpers over a loaded graph (:func:`framework_metadata`, +:func:`unit_element_bindings`, and so on). Attributes ---------- @@ -13,7 +16,42 @@ from importlib.metadata import PackageNotFoundError as _PackageNotFoundError from importlib.metadata import version as _version -__all__ = ["__version__"] +from cybedtools.data import cybed_license, framework_licenses, framework_summary +from cybedtools.fetch import cybed_fetch, load_graph, set_release_url +from cybedtools.queries import ( + element_framework_bindings, + element_text, + example_framework_bindings, + framework_metadata, + organizing_unit_framework_bindings, + role_element_bindings, + role_framework_bindings, + subpoint_framework_bindings, + unit_element_bindings, + unit_relation_bindings, +) +from cybedtools.similarity import framework_similarity + +__all__ = [ + "__version__", + "cybed_fetch", + "cybed_license", + "element_framework_bindings", + "element_text", + "example_framework_bindings", + "framework_licenses", + "framework_metadata", + "framework_similarity", + "framework_summary", + "load_graph", + "organizing_unit_framework_bindings", + "role_element_bindings", + "role_framework_bindings", + "set_release_url", + "subpoint_framework_bindings", + "unit_element_bindings", + "unit_relation_bindings", +] try: __version__ = _version("cybedtools") diff --git a/python/src/cybedtools/_conformance.py b/python/src/cybedtools/_conformance.py new file mode 100644 index 0000000..7024f8d --- /dev/null +++ b/python/src/cybedtools/_conformance.py @@ -0,0 +1,170 @@ +"""Golden-file loading and comparison helpers for conformance tests. + +The conformance contract is ``inst/conformance/README.md`` in the repository +root. These helpers exist so ``tests/test_conformance.py`` states the +contract once (rounding, sort key, NA handling) rather than re-deriving it +per test. + +Fixture location: this module looks for goldens under +``tests/fixtures/conformance/`` first (a copy checked into ``python/`` so +the sdist, which only packages ``python/``, can run its own test suite with +no dependency on the outer repository), then falls back to +``inst/conformance/`` at the repository root (useful when developing inside +a full worktree checkout, so the two locations cannot silently drift without +a test noticing -- see ``test_conformance.py::test_fixture_copy_matches_source``). +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pandas as pd + +_THIS_FILE = Path(__file__).resolve() + + +def _candidate_conformance_dirs() -> list[Path]: + """Directories to search for the conformance fixtures, in priority order. + + An explicit ``CYBEDTOOLS_CONFORMANCE_DIR`` wins. Otherwise the search runs + relative to this file (editable or source checkout) and then relative to + the working directory, which covers a non-editable install tested from + ``python/`` or from the repository root. + """ + candidates: list[Path] = [] + override = os.environ.get("CYBEDTOOLS_CONFORMANCE_DIR") + if override: + candidates.append(Path(override)) + # python/src/cybedtools/_conformance.py -> python/tests/fixtures/conformance + candidates.append(_THIS_FILE.parents[2] / "tests" / "fixtures" / "conformance") + # python/src/cybedtools/_conformance.py -> /inst/conformance + if len(_THIS_FILE.parents) > 3: + candidates.append(_THIS_FILE.parents[3] / "inst" / "conformance") + cwd = Path.cwd() + candidates.append(cwd / "tests" / "fixtures" / "conformance") + candidates.append(cwd / "python" / "tests" / "fixtures" / "conformance") + candidates.append(cwd.parent / "inst" / "conformance") + return candidates + + +def conformance_dir() -> Path: + """Locate the conformance fixtures directory. + + Returns + ------- + Path + The first existing candidate directory. + + Raises + ------ + FileNotFoundError + If neither the packaged copy nor the repository-root copy exists. + """ + for candidate in _candidate_conformance_dirs(): + if candidate.is_dir(): + return candidate + searched = "\n".join(str(candidate) for candidate in _candidate_conformance_dirs()) + msg = f"Could not locate the conformance fixtures directory. Searched:\n{searched}" + raise FileNotFoundError(msg) + + +def load_golden(name: str) -> pd.DataFrame: + """Load one golden CSV by its base name (without ``.csv``). + + Parameters + ---------- + name : str + E.g. ``"framework_summary"`` or ``"cybed_license"``. + + Returns + ------- + pandas.DataFrame + Read with pandas' default (numpy-backed) dtypes and empty fields as + ``NaN``, exactly as the golden CSV encodes it -- comparison against + an implementation's DataFrame should normalize both sides the same + way rather than relying on this loader to guess a target dtype. + """ + path = conformance_dir() / "goldens" / f"{name}.csv" + return pd.read_csv(path, keep_default_na=True, na_values=[""]) + + +def assert_matches_golden( + actual: pd.DataFrame, golden_name: str, *, sort_by: list[str] | None = None +) -> None: + """Assert a DataFrame matches a golden CSV per the conformance contract. + + Applies the README's rules before comparing: sorts both sides by + ``sort_by`` (if given; otherwise assumes both are already in the + contract's row order), resets the index, and compares column-by-column + as strings so that dtype differences (pyarrow ``Int64`` vs. golden + ``int64``, ``boolean[pyarrow]`` vs. ``True``/``False`` text, and so on) + do not themselves cause a false mismatch -- the contract is about + *values*, not pandas dtypes. ``NA`` on either side compares equal to + ``NA`` on the other. Any ``score``-like float column is rounded to 10 + decimal places before the string comparison, per the README. + + Parameters + ---------- + actual : pandas.DataFrame + The implementation's output. + golden_name : str + Passed to :func:`load_golden`. + sort_by : list[str] or None + Columns to sort both frames by before comparing. ``None`` skips + sorting (the caller has already sorted, or order does not matter + for this golden). + + Raises + ------ + AssertionError + If shapes, columns, or values differ. + """ + golden = load_golden(golden_name) + + actual = actual.copy() + golden = golden.copy() + + if sort_by: + actual = actual.sort_values(sort_by).reset_index(drop=True) + golden = golden.sort_values(sort_by).reset_index(drop=True) + else: + actual = actual.reset_index(drop=True) + golden = golden.reset_index(drop=True) + + assert list(actual.columns) == list(golden.columns), ( + f"Column mismatch.\nActual: {list(actual.columns)}\nGolden: {list(golden.columns)}" + ) + assert len(actual) == len(golden), ( + f"Row count mismatch: actual={len(actual)}, golden={len(golden)}" + ) + + for column in golden.columns: + actual_col = _normalize_column(actual[column]) + golden_col = _normalize_column(golden[column]) + mismatches = actual_col.compare(golden_col) + assert mismatches.empty, f"Column '{column}' does not match golden:\n{mismatches}" + + +def _normalize_column(series: pd.Series) -> pd.Series: + """Coerce a column to a comparable string form (numbers rounded, NA unified). + + Parameters + ---------- + series : pandas.Series + A single column from either the actual or golden DataFrame. + + Returns + ------- + pandas.Series + Strings, with ``pandas.NA``/``NaN`` normalized to a single sentinel + and any float values rounded to 10 decimal places before + stringification (the README's similarity-``score`` rounding rule; + harmless to apply to non-score numeric columns since none carry + more than one decimal in the current goldens). + """ + if pd.api.types.is_float_dtype(series) or str(series.dtype).startswith("double"): + series = series.round(10) + if pd.api.types.is_datetime64_any_dtype(series): + return series.dt.strftime("%Y-%m-%d").fillna("") + return series.map(lambda value: "" if pd.isna(value) else str(value)) diff --git a/python/src/cybedtools/_graph.py b/python/src/cybedtools/_graph.py new file mode 100644 index 0000000..0d749c1 --- /dev/null +++ b/python/src/cybedtools/_graph.py @@ -0,0 +1,92 @@ +"""Graph backend for parsed N-Triples data. + +The default backend is `rdflib `_, a pure +Python RDF library with no compiled dependency, matching the low-friction +install the R package gets from ``rdflib`` (the R package of the same name, +itself a wrapper). A second backend, `pyoxigraph +`_, is available as an optional extra +(``pip install cybedtools[oxigraph]``) for callers who load the full +multi-framework corpus and want faster SPARQL evaluation; pyoxigraph ships +compiled wheels for the common platforms, so it is kept optional rather than +a hard dependency. + +Query helpers (``framework_metadata()``, ``unit_element_bindings()``, and so +on) are out of scope for this module. This module only owns turning a set of +N-Triples file paths into one shared graph object. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +GraphBackend = Literal["rdflib", "pyoxigraph"] + +_DEFAULT_BACKEND: GraphBackend = "rdflib" + + +class BackendNotAvailableError(ImportError): + """Raised when a requested graph backend is not installed.""" + + +def parse_graph(paths: list[Path], *, backend: GraphBackend = _DEFAULT_BACKEND) -> object: + """Parse one or more N-Triples files into a single shared graph. + + Parameters + ---------- + paths : list[Path] + Paths to plain-text (already decompressed) N-Triples files. + backend : {"rdflib", "pyoxigraph"} + Which graph implementation to build. ``"rdflib"`` is the default and + has no extra install requirement. ``"pyoxigraph"`` requires the + ``oxigraph`` extra. + + Returns + ------- + object + An ``rdflib.Graph`` when ``backend="rdflib"``, or a + ``pyoxigraph.Store`` when ``backend="pyoxigraph"``. + + Raises + ------ + BackendNotAvailableError + If ``backend="pyoxigraph"`` is requested but pyoxigraph is not + installed. + ValueError + If ``backend`` is not one of the two supported values. + """ + if backend == "rdflib": + return _parse_rdflib(paths) + if backend == "pyoxigraph": + return _parse_pyoxigraph(paths) + msg = f"Unknown graph backend: {backend!r}. Expected 'rdflib' or 'pyoxigraph'." + raise ValueError(msg) + + +def _parse_rdflib(paths: list[Path]) -> object: + try: + import rdflib + except ImportError as exc: # pragma: no cover - rdflib is a hard dependency + msg = "rdflib is required for the default graph backend." + raise BackendNotAvailableError(msg) from exc + + graph = rdflib.Graph() + for path in paths: + graph.parse(str(path), format="nt") + return graph + + +def _parse_pyoxigraph(paths: list[Path]) -> object: + try: + import pyoxigraph + except ImportError as exc: + msg = ( + "pyoxigraph is not installed. Install it with " + "`pip install cybedtools[oxigraph]` to use backend='pyoxigraph'." + ) + raise BackendNotAvailableError(msg) from exc + + store = pyoxigraph.Store() + for path in paths: + store.bulk_load(path=str(path), format=pyoxigraph.RdfFormat.N_TRIPLES) + return store diff --git a/python/src/cybedtools/_resources/cybed_license.csv b/python/src/cybedtools/_resources/cybed_license.csv new file mode 100644 index 0000000..1249e9a --- /dev/null +++ b/python/src/cybedtools/_resources/cybed_license.csv @@ -0,0 +1,16 @@ +layer,slug,framework_name,license_short,license,attribution,public_redistribution,terms_url,granted,verified +framework,ccssf-2022,CCSSF 2022,"Government of Canada, with permission","The Canadian Cyber Security Skills Framework (ITSM.00.039) carries no licence notice of any kind: a sweep of the document finds no copyright line, no Creative Commons statement, no Open Government Licence reference and no all-rights-reserved statement. It carries only the Government of Canada publication identifiers (ISBN 978-0-660-46231-8, Cat. No. D97-4/00-039-2022E-PDF). The document is marked ""TLP:CLEAR"" and ""UNCLASSIFIED / NON CLASSIFIE"" on every page; both are disclosure markings rather than copyright licences. The Canadian Centre for Cyber Security gave cybedtools written permission on 2026-09-21 for full-text publication with attribution.","Canadian Centre for Cyber Security, The Canadian Cyber Security Skills Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada. Used with permission of the Canadian Centre for Cyber Security.",full_with_attribution,https://www.cyber.gc.ca/en/publications,TRUE,2026-09-21 +framework,csec2017-v1,ACM/IEEE CSEC2017,All rights reserved; educational use,"From the CSEC2017 Curricular Guidelines PDF's own copyright page: ""Copyright (c) 2017 by ACM, IEEE, AIS, IFIP / ALL RIGHTS RESERVED / Copyright and Reprint Permissions: Permission is granted to use these curricular guidelines for the development of educational materials and programs. Other use requires specific permission."" (ISBN 978-1-4503-5278-9, DOI 10.1145/3184594.) The grant is limited by purpose rather than by who may use it, and it does not extend to republishing the guidelines themselves, so cybedtools publishes Knowledge Area names, counts and alignment scores and no statement text.",,structure_only,https://cybered.acm.org/,FALSE,2026-09-18 +framework,csta-2017,CSTA K-12 CS (Rev 2017),CC BY-NC-SA 4.0,"The ingested XLSX carries no licence statement, so the label is read from CSTA's 2017-edition publication page: ""These Standards are licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License (CC BY-NC-SA 4.0)."" The share-alike term propagates: any file containing CSTA-derived content must itself go out under CC BY-NC-SA 4.0, which is why cybedtools publishes one graph file per framework rather than one combined file.","Computer Science Teachers Association (2017). CSTA K-12 Computer Science Standards, Revised 2017. Retrieved from https://csteachers.org/k12standards/.",full_with_attribution,https://creativecommons.org/licenses/by-nc-sa/4.0/,FALSE,2026-09-18 +framework,csta-2026,CSTA PK-12 CS (2026),CC BY-NC-SA 4.0,"From the 2026 CSTA PK-12 Computer Science Standards PDF's own licence page (p. iv): ""These Standards are licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. Accordingly, individuals and organizations are free to download, print, share, and adapt the materials in whole or in part, as long as they provide proper attribution, use for non-commercial purposes, and share contributions or derivations under the same license."" Every page footer repeats ""CC BY-NC-SA 4.0"". cybedtools ingests the JSON behind CSTA's interactive Standards Explorer, which carries the same standard text and is published by CSTA, so it is treated under the same terms. The share-alike term propagates to any file containing CSTA-derived content. The attribution below is the PDF's own suggested citation and DOI, with the en dash in PK-12 rendered as a hyphen.",Computer Science Teachers Association. (2026). 2026 CSTA PK-12 computer science standards. https://csteachers.org/pk12standards/ DOI: https://doi.org/10.1145/3820482,full_with_attribution,https://creativecommons.org/licenses/by-nc-sa/4.0/,FALSE,2026-09-21 +code,cybedtools,cybedtools (R package code),MIT,"LICENSE.md, the package's own licence file: ""MIT License / Copyright (c) 2026 Ryan Straight / Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ""Software""), to deal in the Software without restriction..."" The MIT licence covers the code in this repository only. It does not reach any framework's source text, which carries the framework's own terms.",Copyright (c) 2026 Ryan Straight. MIT License.,unrestricted,https://github.com/ryanstraight/cybedtools/blob/main/LICENSE.md,FALSE,2026-09-19 +framework,cyberorg-k12-v1.0,Cyber.org K-12 v1.0,CC BY-NC 4.0,"From the K-12 Cybersecurity Learning Standards PDF's own version-control page: ""K-12 Cybersecurity Learning Standards (c) 2021 by Cyber Innovation Center & CYBER.ORG is licensed under Creative Commons Attribution-NonCommercial 4.0 International"". The same page carries a separate and unconditional grant alongside the licence: ""Authorization to reproduce this report in whole or in part is granted."" The cyber.org/standards page itself states no terms, so the PDF is the only source.",K-12 Cybersecurity Learning Standards. (2021). Retrieved from https://cyber.org/standards.,full_with_attribution,https://creativecommons.org/licenses/by-nc/4.0/,FALSE,2026-09-18 +framework,cybok-v1.1.0,CyBOK v1.1.0,OGL v3.0,"From the copyright page of the Introduction to CyBOK Knowledge Area, Version 1.1.0 (July 2021): ""© Crown Copyright, The National Cyber Security Centre 2021. This information is licensed under the Open Government Licence v3.0."" The same page prescribes the attribution below. The A-to-Z Indicative Material document and each Knowledge Tree carry the same Crown Copyright and Open Government Licence v3.0 notice. The Open Government Licence v3.0 permits copying, publishing, adapting and commercial use on the condition of attribution.","CyBOK © Crown Copyright, The National Cyber Security Centre 2021, licensed under the Open Government Licence: http://www.nationalarchives.gov.uk/doc/open-government-licence/.",full_with_attribution,https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/,FALSE,2026-09-21 +framework,cyqual-v1.2.0,CyQUAL 1.2.0,"Open data, attribution required","The open-data JSON export carries no rights field of any kind. The CyQUAL team at Masaryk University confirmed in writing on 2026-09-16 that the framework is published as open data and publicly available, that a derived RDF/JSON-LD graph may be built from the export, and that the only condition is attribution to CyQUAL and to Masaryk University.","CyQUAL, the Czech national cybersecurity qualifications framework, developed at Masaryk University. Open data, version 1.2.0, https://platform.cyqual.cz/.",full_with_attribution,https://platform.cyqual.cz/en,TRUE,2026-09-18 +framework,dcwf-v5.1,DCWF v5.1,"US Government work, no US copyright","The DoD Cyber Workforce Framework Work Role Tool v5.1 workbook carries no rights or distribution statement in any of its 79 sheets, and the DoD download pages sit behind authentication, so no download-page statement could be read as of 2026-09-18. The governing position is therefore inferred rather than quoted: a work of the US Government prepared by DoD personnel is not subject to copyright in the United States under 17 U.S.C. 105. Nine work roles carry a ""(CUI)"" tag in the workbook's role index; review those sheets before any public deposit of DCWF-derived content.",,unrestricted,https://www.cyberworkforce.mil/Department-Cyber-Workforce-Framework/,FALSE,2026-09-18 +framework,digcomp-3.0,DigComp 3.0,CC BY 4.0,"From the copyright notice distributed with the official DigComp 3.0 data supplement (JRC144121, ISBN 978-92-68-32677-0): ""(c) European Union, 1995-2026 / The Commission's reuse policy is implemented by the Commission Decision of 12 December 2011 on the reuse of Commission documents. Any copyright and/or sui generis right on the dataset is licensed under the Creative Commons Attribution 4.0 International (CC BY 4.0) licence. Reuse is allowed provided appropriate credit is given and any changes are indicated."" Also confirmed on the JRC Data Catalogue record (DOI 10.2905/JRC.FR75K8R) and the DigComp 3.0 resources page, which additionally excludes the European Commission logo from reuse; cybedtools reproduces no logo.","Cosgrove, J. and Cachia, R., DigComp 3.0: The Digital Competence Framework for Citizens, EUR 40491, Publications Office of the European Union, Luxembourg, 2025, ISBN 978-92-68-32677-0, doi:10.2760/0001149. Dataset: doi:10.2905/JRC.FR75K8R.",full_with_attribution,https://creativecommons.org/licenses/by/4.0/,FALSE,2026-09-21 +framework,ecsf-v1,ECSF v1,CC BY 4.0 (report); data files unmarked,"Two things are true. The ECSF Role Profiles report PDF (September 2022, ISBN 978-92-9204-584-5, DOI 10.2824/859537) carries its own copyright notice: ""(c) European Union Agency for Cybersecurity (ENISA), 2022 / This publication is licenced under CC-BY 4.0 ... the reuse of this document is authorised under the Creative Commons Attribution 4.0 International (CC BY 4.0) licence"", and its legal notice adds ""All references to it or its use as a whole or partially must contain ENISA as its source."" The JSON and XLSX files cybedtools ingests carry no notice of any kind, and rest instead on ENISA's site-wide legal notice: ""Reproduction of ENISA material published on this website is authorized, provided the source is acknowledged, unless it is stated otherwise."" ENISA prescribes no citation wording for the report or the data files, so the attribution below is the package's own and follows what the CC BY 4.0 licence requires.","European Union Agency for Cybersecurity (ENISA), European Cybersecurity Skills Framework (ECSF) Role Profiles, September 2022, ISBN 978-92-9204-584-5, DOI 10.2824/859537. (c) ENISA 2022, licensed under CC BY 4.0. Source: https://www.enisa.europa.eu/publications/european-cybersecurity-skills-framework-role-profiles",full_with_attribution,https://www.enisa.europa.eu/publications/european-cybersecurity-skills-framework-role-profiles,FALSE,2026-09-19 +framework,nice-v2,NICE v2.2.0,US public domain + NIST worldwide grant,"The ingested CPRT JSON carries no rights statement, so NIST's published statements govern. NIST, ""Copyright, Fair Use, and Licensing Statements for SRD, Data, Software and Technical Series Publications"", Technical Series section: ""Works authored by NIST employees are not subject to Copyright protection within the United States; foreign rights are reserved. To the extent NIST may assert rights outside of the United States, the public is granted the non-exclusive, perpetual, paid-up, royalty-free, worldwide right to reprint works in all formats including print, electronically, and online, and in all subsequent editions, and derivative works."" NIST's copyright page adds that ""Use of appropriate byline/photo/image credits is requested.""",,unrestricted,https://www.nist.gov/open/copyright-fair-use-and-licensing-statements-srd-data-software-and-technical-series-publications,FALSE,2026-09-18 +framework,otccf-v1.1,OTCCF v1.1,CSA copyright; non-commercial academic,"""(c) Cyber Security Agency of Singapore"" appears on every page of the Operational Technology Cybersecurity Competency Framework PDF, and the document carries no licence beyond that copyright line. The Cyber Security Agency of Singapore granted cybedtools written permission on 2026-09-15 to reference and integrate the OTCCF's structure for non-commercial, academic and research purposes, on two conditions: the attribution wording below word for word, and that the ingestion scripts and structural mappings not misrepresent or alter the intent of the OTCCF's job roles, skills, or competency mappings as published by CSA. The grant covers structure only: roles, skill titles, categories, proficiency levels and role-to-skill mappings, not statement text. The document names Mercer Singapore as a joint developer; whether Mercer holds any interest CSA's grant does not reach is unresolved.","Derived from the Operational Technology Cybersecurity Competency Framework (OTCCF), published by the Cyber Security Agency of Singapore (CSA). Available at: https://www.csa.gov.sg/resources/publications/operational-technology-cybersecurity-competency-framework--otccf-/",structure_only,https://www.csa.gov.sg/resources/publications/operational-technology-cybersecurity-competency-framework--otccf-/,TRUE,2026-09-18 +framework,scywf-1.5,SCyWF 1.5,NCA written permission,"The Saudi Cybersecurity Workforce Framework (SCyWF – 1.5: 2026) PDF carries no copyright line and no licence notice. Its only markings are ""TLP: Clear"" and ""Document Classification: Public"", which are disclosure markings rather than licences. The National Cybersecurity Authority (NCA) of the Kingdom of Saudi Arabia granted cybedtools written permission on 2026-09-20 to ingest and redistribute the framework's content, on three conditions. (1) NCA is cited as issuing body and source, with a link to the official SCyWF page. (2) Everything taken from the document (identifiers, titles, statement text) is carried verbatim, with no normalised labels, paraphrase, in-house translation or composed text presented as NCA content. (3) Anything cybedtools derives, such as hierarchy edges or cross-framework mappings, is marked as derived and not presented as NCA content. NCA prescribed no attribution wording, so the attribution below is composed to meet the first condition. The grant named an earlier edition (SCyWF - 1 : 2020) that NCA no longer publishes. The current edition is ingested by owner decision. The document's own disclaimer makes its Arabic version binding for matters of meaning and interpretation. cybedtools ingests the English version.","The Saudi Cybersecurity Workforce Framework (SCyWF – 1.5: 2026). Issued by the National Cybersecurity Authority (NCA), Kingdom of Saudi Arabia. Source: https://nca.gov.sa/en/pages/scywf.html",full_with_attribution,https://nca.gov.sa/en/pages/scywf.html,TRUE,2026-09-21 +framework,sfia-9,SFIA 9,SFIA Foundation licence required,"SFIA Foundation, ""Using and licensing SFIA"": ""Note: All use of SFIA is under licence."" / ""The SFIA Framework, guidance and support assets are the intellectual property of the SFIA Foundation."" / ""Copying of this material is prohibited unless authorised in writing or under a valid SFIA licence obtained from the SFIA Foundation."" The SFIA General Terms and Conditions define the protected material as ""The concept, content and structure of SFIA along with content from the SFIA website"", and name ""a skills database containing SFIA information"" as a royalty-bearing dependent product, so there is no structure-versus-text carve-out. The third-party structural extract cybedtools reads carries no licence file of any kind and holds no SFIA licence it could pass on. SFIA content in cybedtools is local-analysis only; nothing beyond aggregate counts is published.",,local_only,https://sfia-online.org/en/about-sfia/licensing-sfia/using-and-licensing-sfia,FALSE,2026-09-18 diff --git a/python/src/cybedtools/_resources/framework_summary.csv b/python/src/cybedtools/_resources/framework_summary.csv new file mode 100644 index 0000000..fcbc5a8 --- /dev/null +++ b/python/src/cybedtools/_resources/framework_summary.csv @@ -0,0 +1,15 @@ +framework_slug,framework_name,framework_type,jurisdiction,organizing_unit_count,role_count,element_count_strict,subpoint_count,example_count,element_count_with_examples,unit_relation_count,elements_per_organizing_unit_strict,elements_per_organizing_unit_with_examples,elements_per_role_strict,elements_per_role_with_examples,license +ccssf-2022,CCSSF 2022,workforce,CA,59,59,1148,197,0,1345,0,19.5,22.8,19.5,22.8,"Government of Canada, with permission" +csec2017-v1,ACM/IEEE CSEC2017,pedagogy,global,8,,38,2,0,40,0,4.8,5,,,All rights reserved; educational use +csta-2017,CSTA K-12 CS (Rev 2017),pedagogy,US,25,,120,24,114,258,0,4.8,10.3,,,CC BY-NC-SA 4.0 +csta-2026,CSTA PK-12 CS (2026),pedagogy,US,53,,331,0,652,983,0,6.2,18.5,,,CC BY-NC-SA 4.0 +cyberorg-k12-v1.0,Cyber.org K-12 v1.0,pedagogy,US,116,,123,0,369,492,0,1.1,4.2,,,CC BY-NC 4.0 +cybok-v1.1.0,CyBOK v1.1.0,pedagogy,UK,21,,119,477,0,596,0,5.7,28.4,,,OGL v3.0 +cyqual-v1.2.0,CyQUAL 1.2.0,workforce,CZ,161,102,2488,852,0,3340,0,15.5,20.7,22.9,30.9,"Open data, attribution required" +dcwf-v5.1,DCWF v5.1,workforce,US,74,74,2945,1107,0,4052,0,39.8,54.8,39.8,54.8,"US Government work, no US copyright" +digcomp-3.0,DigComp 3.0,pedagogy,EU,26,,362,0,522,884,0,13.9,34,,,CC BY 4.0 +ecsf-v1,ECSF v1,workforce,EU,12,12,374,16,0,390,0,31.2,32.5,31.2,32.5,CC BY 4.0 (report); data files unmarked +nice-v2,NICE v2.2.0,workforce,US,53,42,2211,14,0,2225,0,41.7,42,44.4,44.7,US public domain + NIST worldwide grant +otccf-v1.1,OTCCF v1.1,workforce,SG,61,15,1612,0,0,1612,310,26.4,26.4,18,18,CSA copyright; non-commercial academic +scywf-1.5,SCyWF 1.5,workforce,SA,81,40,1419,0,0,1419,0,17.5,17.5,35.5,35.5,NCA written permission +sfia-9,SFIA 9,workforce,global,147,,672,149,0,821,0,4.6,5.6,,,SFIA Foundation licence required diff --git a/python/src/cybedtools/data.py b/python/src/cybedtools/data.py new file mode 100644 index 0000000..e4e54f6 --- /dev/null +++ b/python/src/cybedtools/data.py @@ -0,0 +1,157 @@ +"""Accessors for the package's shipped reference data. + +Mirrors ``R/licenses.R`` and the shipped ``framework_summary`` / +``framework_licenses`` tibbles documented in ``R/data.R``. The CSV files +under ``cybedtools/_resources/`` are generated from the R package's own data +by ``python/tools/export_r_data.R`` (see that script's docstring for how and +when to re-run it) and are never hand-edited. That directory is named +``_resources`` rather than ``data`` to avoid colliding with this module's +own name (``cybedtools.data``). + +Column dtypes use pandas' pyarrow-backed extension types +(``dtype_backend="pyarrow"``) rather than legacy NumPy dtypes, so integer +columns with missing values (``role_count`` is ``NA`` for frameworks with no +role construct) stay integer instead of silently upcasting to float, and +string columns stay nullable ``string[pyarrow]`` rather than ``object``. +""" + +from __future__ import annotations + +from functools import lru_cache +from importlib.resources import files + +import pandas as pd + +__all__ = ["cybed_license", "framework_licenses", "framework_summary"] + + +class CybedtoolsFrameworkNotFoundError(ValueError): + """No licence row exists for the requested slug.""" + + +class CybedtoolsScalarInputError(TypeError): + """``slug`` was not a single string or ``None``.""" + + +def _read_package_csv(name: str, *, bool_columns: tuple[str, ...] = (), date_columns: tuple[str, ...] = ()) -> pd.DataFrame: + """Read one shipped CSV from ``cybedtools/_resources/`` into a DataFrame. + + Parameters + ---------- + name : str + Filename under the package's ``_resources/`` directory. + bool_columns : tuple[str, ...] + Columns to read as nullable boolean, given as literal ``TRUE``/ + ``FALSE`` (R's serialization, not Python's ``True``/``False``). + date_columns : tuple[str, ...] + Columns to parse as dates. + + Returns + ------- + pandas.DataFrame + Parsed with the pyarrow dtype backend; empty CSV fields become + ``pandas.NA``. + """ + resource = files("cybedtools").joinpath("_resources", name) + with resource.open("rb") as handle: + frame = pd.read_csv( + handle, + dtype_backend="pyarrow", + true_values=["TRUE"], + false_values=["FALSE"], + parse_dates=list(date_columns) or None, + ) + for column in bool_columns: + frame[column] = frame[column].astype("boolean[pyarrow]") + return frame + + +@lru_cache(maxsize=1) +def framework_summary() -> pd.DataFrame: + """One row per framework in the cybedtools corpus. + + Mirrors the R package's ``framework_summary`` data tibble exactly + (column names, order, and values); see its roxygen documentation in + ``R/data.R`` for what each column means. + + Returns + ------- + pandas.DataFrame + Sorted by ``framework_slug``, matching + ``inst/conformance/goldens/framework_summary.csv``. + """ + return _read_package_csv("framework_summary.csv") + + +@lru_cache(maxsize=1) +def framework_licenses() -> pd.DataFrame: + """Licence facts for the package code and every framework. + + Mirrors the R package's ``framework_licenses`` data tibble exactly. One + row for the package's own code (``slug == "cybedtools"``) and one row + per framework. See ``R/data.R`` for the full column documentation. + + Returns + ------- + pandas.DataFrame + Sorted by ``slug``, matching + ``inst/conformance/goldens/cybed_license.csv``. + """ + return _read_package_csv( + "cybed_license.csv", + bool_columns=("granted",), + date_columns=("verified",), + ) + + +def cybed_license(slug: str | None = None) -> pd.DataFrame: + """Look up the licence terms for the package or one framework. + + Mirrors ``R/licenses.R``'s ``cybed_license()``. Called with no argument + it returns the whole :func:`framework_licenses` table. Called with a + slug it returns that one row. + + Parameters + ---------- + slug : str or None + Either ``"cybedtools"`` for the package's own code, or a framework + slug as carried by ``framework_summary()["framework_slug"]`` (for + example ``"nice-v2"``, ``"otccf-v1.1"``). ``None``, the default, + returns every row. + + Returns + ------- + pandas.DataFrame + Every row when ``slug`` is ``None``, otherwise a single-row + DataFrame. + + Raises + ------ + CybedtoolsScalarInputError + If ``slug`` is not a string or ``None``. + CybedtoolsFrameworkNotFoundError + If ``slug`` does not match any row. + """ + licenses = framework_licenses() + + if slug is None: + return licenses + + if not isinstance(slug, str): + raise CybedtoolsScalarInputError( + f"`slug` must be a single non-missing string, or None. " + f"Received: {type(slug).__name__}." + ) + + row = licenses[licenses["slug"] == slug] + + if row.empty: + known = ", ".join(licenses["slug"].tolist()) + raise CybedtoolsFrameworkNotFoundError( + f"No licence row for that slug.\n" + f"Slug: '{slug}'.\n" + f"Known slugs: {known}.\n" + "Call `cybed_license()` with no argument for every row." + ) + + return row.reset_index(drop=True) diff --git a/python/src/cybedtools/fetch.py b/python/src/cybedtools/fetch.py new file mode 100644 index 0000000..2646e7c --- /dev/null +++ b/python/src/cybedtools/fetch.py @@ -0,0 +1,377 @@ +"""User-facing data loader for the public per-framework data release. + +Mirrors ``R/cybed-fetch.R``: ``cybed_fetch()`` downloads and hash-verifies +release files into a per-platform user cache directory and never writes +anywhere else. ``load_graph()`` turns cached files into a graph object. + +Release URL resolution order (mirroring the R package's +``getOption("cybedtools.release_url")`` then ``Sys.getenv("CYBEDTOOLS_RELEASE_URL")`` +then a built-in default): + +1. :func:`set_release_url` (a module-level override, the closest Python + equivalent to an R option -- R has no per-session option store outside + its global options list, and a module attribute is the idiomatic + stand-in). +2. The ``CYBEDTOOLS_RELEASE_URL`` environment variable. +3. The built-in default, this repository's GitHub release assets. + +Downloading is done with the standard library (``urllib.request``) rather +than a dependency such as ``pooch``: the only features needed are a GET of a +small file over ``http(s)://`` or ``file://`` and a SHA-256 check against a +value already carried in the manifest, and stdlib covers both with no +transitive dependency risk. If resumable/chunked downloads of much larger +files become necessary later, revisit this call. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import tempfile +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse + +import pandas as pd +import platformdirs + +from cybedtools._graph import GraphBackend, parse_graph + +__all__ = [ + "CybedtoolsDownloadFailedError", + "CybedtoolsFrameworkNotFoundError", + "CybedtoolsHashMismatchError", + "cybed_fetch", + "load_graph", + "set_release_url", +] + +_DEFAULT_BASE_URL = "https://github.com/ryanstraight/cybedtools/releases/download/data-v{version}" + +_release_url_override: str | None = None + + +class CybedtoolsDownloadFailedError(RuntimeError): + """A release file (manifest or framework archive) could not be downloaded.""" + + +class CybedtoolsFrameworkNotFoundError(ValueError): + """A requested framework slug is not present in the release manifest.""" + + +class CybedtoolsHashMismatchError(RuntimeError): + """A downloaded file's SHA-256 did not match the manifest's declared hash.""" + + +def set_release_url(url: str | None) -> None: + """Set (or clear) a process-wide override for the release base URL. + + The closest Python equivalent to the R package's + ``options(cybedtools.release_url = ...)``. Pass ``None`` to clear the + override and fall back to the ``CYBEDTOOLS_RELEASE_URL`` environment + variable or the built-in default. + + Parameters + ---------- + url : str or None + A base URL, optionally carrying a literal ``{version}`` placeholder + (see :func:`_release_url_for`). ``None`` clears the override. + """ + global _release_url_override + _release_url_override = url + + +def _release_base_url() -> str: + """Resolve the configured release base URL. + + Returns + ------- + str + A URL with no trailing slash. + """ + if _release_url_override: + return _release_url_override.rstrip("/") + env = os.environ.get("CYBEDTOOLS_RELEASE_URL") + if env: + return env.rstrip("/") + return _DEFAULT_BASE_URL + + +def _release_url_for(base_url: str, version: str, filename: str) -> str: + """Build the URL of one release file (the manifest or a framework's ``.nt.gz``). + + A GitHub release's assets are tagged ``data-v`` and sit + directly under that tag with no further version segment, so a base URL + carrying the ``{version}`` placeholder has the version substituted in + place rather than appended. Every other base URL (a mirror, or a mock + release directory used in tests) nests each version below the fixed + base. + + Parameters + ---------- + base_url : str + From :func:`_release_base_url`. + version : str + Release version (or ``"latest"``). + filename : str + E.g. ``"manifest.json"`` or ``".nt.gz"``. + + Returns + ------- + str + The resolved URL. + """ + if "{version}" in base_url: + return base_url.replace("{version}", version) + "/" + filename + return f"{base_url}/{version}/{filename}" + + +def _cache_dir() -> Path: + """The package's cache directory, created if it does not yet exist. + + Uses :mod:`platformdirs` rather than hand-rolled XDG logic: it is the + de facto standard for per-platform user cache/config/data directories in + Python (analogous to R's ``tools::R_user_dir()``) and is a small, + dependency-free, widely vendored package. + + Returns + ------- + Path + The cache directory root (not version-scoped). + """ + directory = Path(platformdirs.user_cache_dir("cybedtools")) + directory.mkdir(parents=True, exist_ok=True) + return directory + + +def _download(url: str, destfile: Path) -> None: + """Download one URL to one destination path, for ``http(s)://`` and ``file://``. + + Parameters + ---------- + url : str + Source URL. + destfile : Path + Destination path. Written atomically (via a temp file in the same + directory, then renamed) so a failed download never leaves a partial + file at the final path. + + Raises + ------ + CybedtoolsDownloadFailedError + If the URL cannot be read, or the result is empty. + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https", "file"): + msg = f"Unsupported URL scheme: {parsed.scheme!r} (url: {url})" + raise CybedtoolsDownloadFailedError(msg) + + destfile.parent.mkdir(parents=True, exist_ok=True) + tmp_fd, tmp_name = tempfile.mkstemp(dir=str(destfile.parent)) + tmp_path = Path(tmp_name) + try: + with os.fdopen(tmp_fd, "wb") as tmp_handle: + try: + with urllib.request.urlopen(url) as response: # noqa: S310 - scheme checked above + shutil.copyfileobj(response, tmp_handle) + except (OSError, ValueError) as exc: + raise CybedtoolsDownloadFailedError( + f"Could not download a release file.\n" + f"URL: {url}.\n" + "Call `set_release_url(...)` or set the CYBEDTOOLS_RELEASE_URL " + "environment variable to point at a reachable release " + "(a file:// URL works for local testing)." + ) from exc + if tmp_path.stat().st_size == 0: + msg = f"Could not download a release file (empty response).\nURL: {url}." + raise CybedtoolsDownloadFailedError(msg) + os.replace(tmp_path, destfile) + finally: + tmp_path.unlink(missing_ok=True) + + +def _sha256_of(path: Path) -> str: + """Compute the SHA-256 hex digest of a file's bytes. + + Parameters + ---------- + path : Path + File to hash. + + Returns + ------- + str + Lowercase hex digest. + """ + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _read_manifest(base_url: str, version: str) -> dict: + """Download and parse a release's ``manifest.json``. + + Parameters + ---------- + base_url : str + From :func:`_release_base_url`. + version : str + Release version segment (or ``"latest"``). + + Returns + ------- + dict + The parsed manifest. + """ + url = _release_url_for(base_url, version, "manifest.json") + with tempfile.TemporaryDirectory() as tmp_dir: + tmp = Path(tmp_dir) / "manifest.json" + _download(url, tmp) + return json.loads(tmp.read_text(encoding="utf-8")) + + +@dataclass(frozen=True) +class FetchedFramework: + """One row of a :func:`cybed_fetch` result.""" + + framework_slug: str + path: str + sha256_verified: bool + + +def cybed_fetch( + frameworks: list[str] | None = None, version: str | None = None +) -> pd.DataFrame: + """Download and hash-verify per-framework release files into the user cache. + + Mirrors ``R/cybed-fetch.R``'s ``cybed_fetch()``. Already-cached files + whose hash still matches the manifest are not re-downloaded. + + Parameters + ---------- + frameworks : list[str] or None + Framework slugs to fetch (as carried by + ``framework_summary()["framework_slug"]``, e.g. ``"nice-v2"``), or + ``None`` (the default) for every framework the release manifest + ships. + version : str or None + Release version (e.g. ``"1.0.0"``), or ``None`` (the default) for + the release location's ``"latest"`` alias. + + Returns + ------- + pandas.DataFrame + One row per fetched framework: columns ``framework_slug``, ``path`` + (the cached file's local path, as a string), ``sha256_verified`` + (bool, always ``True`` on return -- a mismatch raises instead of + returning ``False``). + + Raises + ------ + CybedtoolsDownloadFailedError + If the manifest or a framework file cannot be downloaded. + CybedtoolsFrameworkNotFoundError + If a requested slug is not in the release manifest. + CybedtoolsHashMismatchError + If a downloaded file's SHA-256 does not match the manifest. + """ + base_url = _release_base_url() + resolved_version = version if version is not None else "latest" + manifest = _read_manifest(base_url, resolved_version) + + files = manifest["files"] + manifest_slugs = [entry["slug"] for entry in files] + + requested = frameworks if frameworks is not None else manifest_slugs + unknown = sorted(set(requested) - set(manifest_slugs)) + if unknown: + raise CybedtoolsFrameworkNotFoundError( + f"Unknown framework slug(s) for this release: {', '.join(unknown)}. " + f"Known slugs: {', '.join(manifest_slugs)}." + ) + + cache_dir = _cache_dir() / resolved_version + cache_dir.mkdir(parents=True, exist_ok=True) + + by_slug = {entry["slug"]: entry for entry in files} + rows: list[FetchedFramework] = [] + for slug in requested: + entry = by_slug[slug] + dest = cache_dir / entry["file"] + + needs_download = not dest.exists() or _sha256_of(dest) != entry["sha256"] + + if needs_download: + url = _release_url_for(base_url, resolved_version, entry["file"]) + _download(url, dest) + actual = _sha256_of(dest) + if actual != entry["sha256"]: + dest.unlink(missing_ok=True) + raise CybedtoolsHashMismatchError( + f"Downloaded file does not match the release manifest's hash.\n" + f"Framework: {slug}.\n" + f"Expected sha256: {entry['sha256']}.\n" + f"Got sha256: {actual}.\n" + "The file was deleted rather than cached in an unverified state." + ) + + rows.append( + FetchedFramework(framework_slug=slug, path=str(dest), sha256_verified=True) + ) + + return pd.DataFrame( + { + "framework_slug": [row.framework_slug for row in rows], + "path": [row.path for row in rows], + "sha256_verified": pd.array( + [row.sha256_verified for row in rows], dtype="boolean" + ), + } + ) + + +def load_graph( + frameworks: list[str] | None = None, + version: str | None = None, + *, + backend: GraphBackend = "rdflib", +) -> object: + """Load a graph from the cached release files, fetching first if needed. + + Ensures the requested frameworks are cached (calling :func:`cybed_fetch` + internally, a no-op for files already cached with a verified hash) and + parses them into one shared graph object. + + Parameters + ---------- + frameworks : list[str] or None + See :func:`cybed_fetch`. + version : str or None + See :func:`cybed_fetch`. + backend : {"rdflib", "pyoxigraph"} + Graph backend to build. See :mod:`cybedtools._graph`. + + Returns + ------- + object + An ``rdflib.Graph`` (default) or ``pyoxigraph.Store``, with every + requested framework's triples loaded. + """ + fetched = cybed_fetch(frameworks=frameworks, version=version) + + import gzip + + with tempfile.TemporaryDirectory() as tmp_dir: + nt_paths: list[Path] = [] + for path_str in fetched["path"]: + gz_path = Path(path_str) + nt_path = Path(tmp_dir) / (gz_path.stem + ".nt") + with gzip.open(gz_path, "rb") as src, nt_path.open("wb") as dst: + shutil.copyfileobj(src, dst) + nt_paths.append(nt_path) + return parse_graph(nt_paths, backend=backend) diff --git a/python/src/cybedtools/queries.py b/python/src/cybedtools/queries.py new file mode 100644 index 0000000..88c01cc --- /dev/null +++ b/python/src/cybedtools/queries.py @@ -0,0 +1,348 @@ +"""Query helpers over a loaded graph. + +Python port of ``R/sparql-helpers.R``. The R package's design discipline is +single-basic-graph-pattern queries (one triple match per query call), with +all joins, multi-property assembly, and aggregation done afterward via +``dplyr``. This module mirrors that discipline: each single-BGP step is a +direct rdflib triple lookup (``graph.subjects()`` / ``graph.subject_objects()``, +the Python equivalent of a one-triple-pattern SPARQL SELECT), and the +domain-level helpers stitch those results together with ``pandas`` merges +that reproduce the R functions' ``inner_join`` / ``left_join`` / ``semi_join`` +semantics column-for-column, including NA propagation and fan-out on +duplicate keys. + +Function names, argument names, return column names and order, the +``framework_slug`` derivation, and NA semantics all match the R helpers +exactly; see ``inst/conformance/README.md`` for the golden-file contract +these functions are tested against. +""" + +from __future__ import annotations + +import warnings + +import pandas as pd +import rdflib + +__all__ = [ + "framework_metadata", + "role_framework_bindings", + "organizing_unit_framework_bindings", + "element_framework_bindings", + "example_framework_bindings", + "subpoint_framework_bindings", + "unit_element_bindings", + "unit_relation_bindings", + "element_text", + "role_element_bindings", +] + +_CYBED = "https://w3id.org/cybed/ontology#" +_SCHEMA = "http://schema.org/" +_RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" + + +def _cybed(term: str) -> str: + return _CYBED + term + + +def framework_slug_of(framework_uri: pd.Series) -> pd.Series: + """Derive a framework's stable slug from its framework IRI. + + The text after the final ``/`` of the IRI, matching + ``framework_slug_of()`` in ``R/sparql-helpers.R``. ``NA`` input yields + ``NA`` output. + + Parameters + ---------- + framework_uri : pandas.Series + The ``framework`` column of any of this module's helpers. + + Returns + ------- + pandas.Series + Slugs, same length and index as ``framework_uri``. + """ + return framework_uri.map(lambda uri: uri.rsplit("/", 1)[-1] if pd.notna(uri) else pd.NA) + + +def _pairs(graph: rdflib.Graph, predicate: str) -> pd.DataFrame: + """Single-BGP ``SELECT ?s ?o WHERE { ?s ?o }`` equivalent.""" + rows = [(str(s), str(o)) for s, o in graph.subject_objects(rdflib.URIRef(predicate))] + if not rows: + return pd.DataFrame({"s": pd.Series(dtype="object"), "o": pd.Series(dtype="object")}) + return pd.DataFrame(rows, columns=["s", "o"]) + + +def _subjects(graph: rdflib.Graph, predicate: str, obj: str) -> pd.DataFrame: + """Single-BGP ``SELECT ?s WHERE { ?s }`` equivalent.""" + rows = [str(s) for s in graph.subjects(rdflib.URIRef(predicate), rdflib.URIRef(obj))] + if not rows: + return pd.DataFrame({"s": pd.Series(dtype="object")}) + return pd.DataFrame({"s": rows}) + + +def _semi_join_isin(frame: pd.DataFrame, column: str, allowed: pd.DataFrame, allowed_column: str) -> pd.DataFrame: + """``dplyr::semi_join`` equivalent: filter rows without duplicating or adding columns.""" + allowed_values = set(allowed[allowed_column]) + return frame[frame[column].isin(allowed_values)] + + +def framework_metadata(rdf: rdflib.Graph) -> pd.DataFrame: + """Tibble/DataFrame of framework metadata. + + Parameters + ---------- + rdf : rdflib.Graph + A graph from :func:`cybedtools._graph.parse_graph` or + :func:`cybedtools.load_graph`. + + Returns + ------- + pandas.DataFrame + Columns ``framework``, ``name``, ``jurisdiction``, ``sector``, + ``specificity``, ``framework_slug``. One row per framework typed + ``cybed:Framework``. + """ + fw = _subjects(rdf, _RDF_TYPE, _cybed("Framework")).rename(columns={"s": "framework"}) + names_ = _pairs(rdf, _SCHEMA + "name").rename(columns={"s": "framework", "o": "name"}) + juris = _pairs(rdf, _cybed("jurisdiction")).rename(columns={"s": "framework", "o": "jurisdiction"}) + sectors = _pairs(rdf, _cybed("sector")).rename(columns={"s": "framework", "o": "sector"}) + specs = _pairs(rdf, _cybed("specificity")).rename(columns={"s": "framework", "o": "specificity"}) + + result = fw.merge(names_, on="framework", how="left") + result = result.merge(juris, on="framework", how="left") + result = result.merge(sectors, on="framework", how="left") + result = result.merge(specs, on="framework", how="left") + result["framework_slug"] = framework_slug_of(result["framework"]) + return result[["framework", "name", "jurisdiction", "sector", "specificity", "framework_slug"]] + + +def role_framework_bindings(rdf: rdflib.Graph) -> pd.DataFrame: + """Role-to-framework bindings with framework name attached. + + Parameters + ---------- + rdf : rdflib.Graph + + Returns + ------- + pandas.DataFrame + Columns ``role``, ``framework``, ``role_name``, ``framework_name``, + ``framework_slug``. + """ + roles = _subjects(rdf, _RDF_TYPE, _cybed("Role")).rename(columns={"s": "role"}) + fws = _subjects(rdf, _RDF_TYPE, _cybed("Framework")) + names_ = _pairs(rdf, _SCHEMA + "name") + partof = _pairs(rdf, _cybed("partOf")).rename(columns={"s": "role", "o": "framework"}) + + result = roles.merge(partof, on="role", how="inner") + result = _semi_join_isin(result, "framework", fws, "s") + result = result.merge(names_.rename(columns={"s": "role", "o": "role_name"}), on="role", how="left") + result = result.merge( + names_.rename(columns={"s": "framework", "o": "framework_name"}), on="framework", how="left" + ) + result["framework_slug"] = framework_slug_of(result["framework"]) + return result[["role", "framework", "role_name", "framework_name", "framework_slug"]] + + +def organizing_unit_framework_bindings(rdf: rdflib.Graph) -> pd.DataFrame: + """Organizing-unit-to-framework bindings with framework name attached. + + Parameters + ---------- + rdf : rdflib.Graph + + Returns + ------- + pandas.DataFrame + Columns ``unit``, ``framework``, ``unit_name``, ``framework_name``, + ``framework_slug``. + """ + units = _subjects(rdf, _RDF_TYPE, _cybed("OrganizingUnit")).rename(columns={"s": "unit"}) + fws = _subjects(rdf, _RDF_TYPE, _cybed("Framework")) + names_ = _pairs(rdf, _SCHEMA + "name") + partof = _pairs(rdf, _cybed("partOf")).rename(columns={"s": "unit", "o": "framework"}) + + result = units.merge(partof, on="unit", how="inner") + result = _semi_join_isin(result, "framework", fws, "s") + result = result.merge(names_.rename(columns={"s": "unit", "o": "unit_name"}), on="unit", how="left") + result = result.merge( + names_.rename(columns={"s": "framework", "o": "framework_name"}), on="framework", how="left" + ) + result["framework_slug"] = framework_slug_of(result["framework"]) + return result[["unit", "framework", "unit_name", "framework_name", "framework_slug"]] + + +def element_framework_bindings(rdf: rdflib.Graph) -> pd.DataFrame: + """Element-to-framework bindings with framework name attached. + + Parameters + ---------- + rdf : rdflib.Graph + + Returns + ------- + pandas.DataFrame + Columns ``element``, ``framework``, ``framework_name``, + ``framework_slug``. + """ + elements = _subjects(rdf, _RDF_TYPE, _cybed("RoleElement")).rename(columns={"s": "element"}) + fws = _subjects(rdf, _RDF_TYPE, _cybed("Framework")) + partof = _pairs(rdf, _cybed("partOf")).rename(columns={"s": "element", "o": "framework"}) + fw_names = _pairs(rdf, _SCHEMA + "name").rename(columns={"s": "framework", "o": "framework_name"}) + + result = elements.merge(partof, on="element", how="inner") + result = _semi_join_isin(result, "framework", fws, "s") + result = result.merge(fw_names, on="framework", how="left") + result["framework_slug"] = framework_slug_of(result["framework"]) + return result[["element", "framework", "framework_name", "framework_slug"]] + + +def example_framework_bindings(rdf: rdflib.Graph) -> pd.DataFrame: + """Example-to-framework bindings with framework name attached. + + Parameters + ---------- + rdf : rdflib.Graph + + Returns + ------- + pandas.DataFrame + Columns ``example``, ``framework``, ``framework_name``, + ``framework_slug``. + """ + examples = _subjects(rdf, _RDF_TYPE, _cybed("Example")).rename(columns={"s": "example"}) + fws = _subjects(rdf, _RDF_TYPE, _cybed("Framework")) + partof = _pairs(rdf, _cybed("partOf")).rename(columns={"s": "example", "o": "framework"}) + fw_names = _pairs(rdf, _SCHEMA + "name").rename(columns={"s": "framework", "o": "framework_name"}) + + result = examples.merge(partof, on="example", how="inner") + result = _semi_join_isin(result, "framework", fws, "s") + result = result.merge(fw_names, on="framework", how="left") + result["framework_slug"] = framework_slug_of(result["framework"]) + return result[["example", "framework", "framework_name", "framework_slug"]] + + +def subpoint_framework_bindings(rdf: rdflib.Graph) -> pd.DataFrame: + """Subpoint-to-framework bindings with framework name attached. + + Parameters + ---------- + rdf : rdflib.Graph + + Returns + ------- + pandas.DataFrame + Columns ``subpoint``, ``framework``, ``framework_name``, + ``framework_slug``. + """ + subpoints = _subjects(rdf, _RDF_TYPE, _cybed("Subpoint")).rename(columns={"s": "subpoint"}) + fws = _subjects(rdf, _RDF_TYPE, _cybed("Framework")) + partof = _pairs(rdf, _cybed("partOf")).rename(columns={"s": "subpoint", "o": "framework"}) + fw_names = _pairs(rdf, _SCHEMA + "name").rename(columns={"s": "framework", "o": "framework_name"}) + + result = subpoints.merge(partof, on="subpoint", how="inner") + result = _semi_join_isin(result, "framework", fws, "s") + result = result.merge(fw_names, on="framework", how="left") + result["framework_slug"] = framework_slug_of(result["framework"]) + return result[["subpoint", "framework", "framework_name", "framework_slug"]] + + +def element_text(rdf: rdflib.Graph) -> pd.DataFrame: + """Statement text keyed by element. + + Parameters + ---------- + rdf : rdflib.Graph + + Returns + ------- + pandas.DataFrame + Columns ``element`` (the element's full URI) and ``text`` (the + statement literal). Elements without a ``cybed:elementText`` triple + do not appear; an element with more than one such literal yields + one row per literal. + """ + texts = _pairs(rdf, _cybed("elementText")) + return texts.rename(columns={"s": "element", "o": "text"}) + + +def unit_element_bindings(rdf: rdflib.Graph) -> pd.DataFrame: + """Organizing-unit-to-element bindings. + + Parameters + ---------- + rdf : rdflib.Graph + + Returns + ------- + pandas.DataFrame + Columns ``role``, ``element``, ``framework_slug``. Despite the + ``role`` column name, it is not restricted to ``cybed:Role`` + subjects -- ``cybed:hasElement`` is the universal parent-child link + used across all eleven frameworks. A ``role`` with no valid + ``cybed:partOf`` to a ``cybed:Framework`` carries ``NA`` in + ``framework_slug`` rather than dropping the row. + """ + has_element = _pairs(rdf, _cybed("hasElement")).rename(columns={"s": "role", "o": "element"}) + units = organizing_unit_framework_bindings(rdf)[["unit", "framework_slug"]].rename(columns={"unit": "role"}) + return has_element.merge(units, on="role", how="left") + + +def role_element_bindings(rdf: rdflib.Graph) -> pd.DataFrame: + """Deprecated alias for :func:`unit_element_bindings`. + + Deprecated as of cybedtools 0.4.0 in favor of + :func:`unit_element_bindings`, which returns the identical result under + a name that doesn't overstate the role restriction. + + Parameters + ---------- + rdf : rdflib.Graph + + Returns + ------- + pandas.DataFrame + See :func:`unit_element_bindings`. + """ + warnings.warn( + "`role_element_bindings()` was deprecated in cybedtools 0.4.0. " + "Use `unit_element_bindings()` instead.", + DeprecationWarning, + stacklevel=2, + ) + return unit_element_bindings(rdf) + + +def unit_relation_bindings(rdf: rdflib.Graph) -> pd.DataFrame: + """Unit-to-unit relation bindings. + + Parameters + ---------- + rdf : rdflib.Graph + + Returns + ------- + pandas.DataFrame + Columns ``from_unit``, ``relation``, ``to_unit``, + ``framework_slug``. ``relation`` is ``NA`` for a plain (unlabeled) + relation; ``framework_slug`` is ``NA`` when the relation node + carries no valid ``cybed:partOf`` to a ``cybed:Framework``. + """ + relations = _subjects(rdf, _RDF_TYPE, _cybed("UnitRelation")).rename(columns={"s": "relation_id"}) + from_unit = _pairs(rdf, _cybed("fromUnit")).rename(columns={"s": "relation_id", "o": "from_unit"}) + to_unit = _pairs(rdf, _cybed("toUnit")).rename(columns={"s": "relation_id", "o": "to_unit"}) + labels = _pairs(rdf, _cybed("relationLabel")).rename(columns={"s": "relation_id", "o": "relation"}) + partof = _pairs(rdf, _cybed("partOf")).rename(columns={"s": "relation_id", "o": "framework"}) + fws = _subjects(rdf, _RDF_TYPE, _cybed("Framework")) + + fw_of_relation = _semi_join_isin(partof, "framework", fws, "s").copy() + fw_of_relation["framework_slug"] = framework_slug_of(fw_of_relation["framework"]) + fw_of_relation = fw_of_relation[["relation_id", "framework_slug"]] + + result = relations.merge(from_unit, on="relation_id", how="left") + result = result.merge(to_unit, on="relation_id", how="left") + result = result.merge(labels, on="relation_id", how="left") + result = result.merge(fw_of_relation, on="relation_id", how="left") + return result[["from_unit", "relation", "to_unit", "framework_slug"]] diff --git a/python/src/cybedtools/similarity.py b/python/src/cybedtools/similarity.py new file mode 100644 index 0000000..b7aae2a --- /dev/null +++ b/python/src/cybedtools/similarity.py @@ -0,0 +1,244 @@ +"""Cross-framework organizing-unit text similarity. + +Ports ``R/similarity-helpers.R``. Per the approved API proposal +(``2026-09-21_cybedtools-0.4.0-api-proposal.md``, item 4), only +:func:`framework_similarity` is public. The tokenizer, the Jaccard +set-similarity, the stopword list, and the ranking tie-break stay private, +so their details can change without breaking anyone. + +``from`` is a reserved word in Python, so the public function takes +``from_`` where the R signature has ``from``. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +import pandas as pd + +from cybedtools.queries import element_text, organizing_unit_framework_bindings, unit_element_bindings + +__all__ = ["framework_similarity"] + +_TOKEN_SPLIT_RE = re.compile(r"[^a-z0-9]+") + +# Verbatim port of similarity_stopwords()'s "workforce" profile (the +# default used by tokenize()/framework_similarity()). The "k12" profile +# (this base list without the ten extra words below) is not needed by the +# public Python surface, since tokenize() itself is not exported. +_STOPWORDS_BASE = frozenset( + { + "the", "a", "an", "of", "in", "to", "for", "with", "and", "or", "is", "are", "be", "by", + "on", "at", "as", "that", "this", "their", "they", "it", "its", "from", "how", "can", + "use", "using", "used", "may", "will", "such", "but", "not", "do", "have", "has", + "what", "which", "when", "between", "into", "about", "also", "than", "then", "there", + "these", "those", "while", "each", "other", "both", "more", "most", "some", "many", + "include", "including", "includes", "example", "examples", "based", "through", + } +) +_WORKFORCE_EXTRA = frozenset( + {"any", "all", "new", "one", "two", "three", "work", "perform", "performs", "ensure"} +) +_STOPWORDS_WORKFORCE = _STOPWORDS_BASE | _WORKFORCE_EXTRA + + +class CybedtoolsScalarInputError(TypeError): + """A function that expects a length-1 string input received something else.""" + + +def _tokenize(text: str | None, stopwords: frozenset[str] = _STOPWORDS_WORKFORCE) -> list[str]: + """Tokenize a text scalar for Jaccard comparison. + + Lowercases, splits on runs of characters outside ``a-z0-9``, drops + tokens shorter than 3 characters and stopwords, and de-duplicates + (set semantics, order-preserving -- matches R's ``unique()``). + + Mirrors R's ``tokenize()`` exactly, including its lossy behaviors: + 2-character domain terms ("AI", "OS", "5G") are dropped by the + length-3 floor, and non-ASCII letters fall outside ``[^a-z0-9]+``'s + complement so accented text is split on those boundaries too (both + R's regex, evaluated byte/char-literally rather than via a Unicode + letter class, and Python's ``re`` module with a plain ``str`` pattern + treat ``[^a-z0-9]`` the same way: anything not in that literal ASCII + range is a split point, not just non-word characters). + + Parameters + ---------- + text : str or None + A single piece of text, or ``None``/empty string. + stopwords : frozenset[str] + Stopword set; defaults to the "workforce" profile, matching + ``framework_similarity()``'s use of R's ``tokenize()`` default. + + Returns + ------- + list[str] + Unique tokens, in first-seen order. + """ + if text is None or text == "": + return [] + + lowered = text.lower() + raw_tokens = _TOKEN_SPLIT_RE.split(lowered) + + seen: dict[str, None] = {} + for token in raw_tokens: + if len(token) >= 3 and token not in stopwords: + seen.setdefault(token, None) + return list(seen) + + +def _jaccard(a: list[str], b: list[str]) -> float: + """Jaccard similarity of two token lists, treated as sets. + + Either side empty returns exactly 0 -- the deliberate empty-set + convention `R/similarity-helpers.R` documents (not the + mathematical-purist 1 for empty-empty). + """ + set_a = set(a) + set_b = set(b) + if not set_a or not set_b: + return 0.0 + return len(set_a & set_b) / len(set_a | set_b) + + +def _similarity_strength(score: float) -> str: + """Strength tier for a similarity score. Boundaries inclusive (``>=``).""" + if score >= 0.30: + return "strong" + if score >= 0.20: + return "moderate" + if score >= 0.10: + return "weak" + return "none" + + +@dataclass(frozen=True) +class _UnitDoc: + unit: str + tokens: list[str] + + +def _side_documents(graph: object, slug: str) -> list[_UnitDoc]: + """Build one tokenized document per organizing unit in framework `slug`. + + Mirrors `framework_similarity()`'s internal `side()`: each unit's + comparison text is its own name, its own `elementText` when the unit + IRI itself carries one directly, and the concatenated `elementText` + of every element reachable via `cybed:hasElement`. Units whose + assembled text is empty after trimming contribute no document (an + empty document has nothing to compare). + + Built entirely from the public query-graph helpers + (:func:`cybedtools.queries.organizing_unit_framework_bindings`, + :func:`cybedtools.queries.unit_element_bindings`, + :func:`cybedtools.queries.element_text`) rather than re-walking triples, + so this stays in lockstep with those functions' own graph traversal. + """ + units = organizing_unit_framework_bindings(graph) + units = units[units["framework_slug"] == slug].sort_values("unit") + + texts = dict(zip(element_text(graph)["element"], element_text(graph)["text"], strict=True)) + + bindings = unit_element_bindings(graph) + element_bindings: dict[str, list[str]] = {} + for role, element in zip(bindings["role"], bindings["element"], strict=True): + element_bindings.setdefault(role, []).append(element) + + documents: list[_UnitDoc] = [] + for unit, unit_name in zip(units["unit"], units["unit_name"], strict=True): + unit_name = "" if pd.isna(unit_name) else unit_name + own_text = texts.get(unit, "") + child_texts = [texts[element] for element in element_bindings.get(unit, []) if element in texts] + child_text = " ".join(child_texts) + + full_text = f"{unit_name} {own_text} {child_text}".strip() + if not full_text: + continue + + documents.append(_UnitDoc(unit=unit, tokens=_tokenize(full_text))) + + return documents + + +def framework_similarity(graph: object, from_: str, to: str, n: int = 5) -> pd.DataFrame: + """Cross-framework unit-text similarity, top-n matches per unit. + + Reproduces R's ``framework_similarity(rdf, from, to, n)`` (``from`` is + a reserved word in Python, hence ``from_`` here). Each organizing + unit's comparison text is its own name, its own ``cybed:elementText`` + when it carries one directly, and the concatenated + ``cybed:elementText`` of every element reachable via + ``cybed:hasElement``. Every (from-unit, to-unit) pair is scored with + a Jaccard set-similarity over tokenized text, the top ``n`` matches + per from-unit are kept (ties broken by ascending ``to_unit``), and + each score's strength tier is attached. + + Parameters + ---------- + graph : object + A parsed rdflib ``Graph`` (or any object iterable as + ``(subject, predicate, obj)`` triples), as returned by + :func:`cybedtools.load_graph`. + from_ : str + The `framework_slug` whose organizing units are the rows being + matched. + to : str + The `framework_slug` whose organizing units are the candidates. + May equal `from_`, to find near-duplicate units within one + framework. + n : int + Matches to keep per from-unit (default 5). + + Returns + ------- + pandas.DataFrame + One row per (from unit, match), with columns `from_unit`, + `to_unit` (both full IRIs), `score` (float in `[0, 1]`, rounded to + 10 decimal places), `strength` (`"strong"`/`"moderate"`/ + `"weak"`/`"none"`), and `rank` (int, `1..k`, dense per + `from_unit`). A from-side unit with zero candidates on the `to` + side contributes no rows. + """ + if not isinstance(from_, str): + msg = "`from_` must be a string." + raise CybedtoolsScalarInputError(msg) + if not isinstance(to, str): + msg = "`to` must be a string." + raise CybedtoolsScalarInputError(msg) + + empty = pd.DataFrame( + { + "from_unit": pd.Series(dtype="object"), + "to_unit": pd.Series(dtype="object"), + "score": pd.Series(dtype="float64"), + "strength": pd.Series(dtype="object"), + "rank": pd.Series(dtype="int64"), + } + ) + + from_units = _side_documents(graph, from_) + to_units = _side_documents(graph, to) + if not from_units or not to_units: + return empty + + rows: list[dict[str, object]] = [] + for from_doc in from_units: + scored = [ + (to_doc.unit, _jaccard(from_doc.tokens, to_doc.tokens)) for to_doc in to_units + ] + # Deterministic tie-break: descending score, then ascending candidate. + scored.sort(key=lambda pair: (-pair[1], pair[0])) + for rank, (candidate, score) in enumerate(scored[:n], start=1): + rows.append( + { + "from_unit": from_doc.unit, + "to_unit": candidate, + "score": round(score, 10), + "strength": _similarity_strength(score), + "rank": rank, + } + ) + + return pd.DataFrame(rows, columns=["from_unit", "to_unit", "score", "strength", "rank"]) diff --git a/python/tests/fixtures/conformance/fixture.nt b/python/tests/fixtures/conformance/fixture.nt new file mode 100644 index 0000000..f1c9d02 --- /dev/null +++ b/python/tests/fixtures/conformance/fixture.nt @@ -0,0 +1,72 @@ + . + "Monitors network traffic for security incidents and analyzes alerts" . + . + . + "Configures firewalls and defends the network perimeter" . + . + . + "Monitors network traffic for incidents and coordinates incident response" . + . + . + "Hunts for adversaries hiding in network traffic and endpoint logs" . + . + . + . + "Clarification statement: includes GDPR-relevant scenarios" . + . + . + . + . + "chiffrement et contr\u00F4le d'acc\u00E8s" . + . + . + "Prot\u00E9ger les donn\u00E9es personnelles et respecter la vie priv\u00E9e" . + . + "Fixture Pedagogy Framework One" . + . + "global" . + "education" . + "cybersecurity-specific" . + "Fixture Workforce Framework One" . + . + "US" . + "civilian" . + "cybersecurity-specific" . + "Fixture Workforce Framework Two" . + . + "EU" . + "civilian" . + "cybersecurity-specific" . + . + . + . + "requires" . + . + "Security Analyst" . + . + . + . + . + "Network Defender" . + . + . + . + . + . + "Incident Handler" . + . + . + . + . + "Threat Hunter" . + . + . + . + . + . + "Grade Band Data Protection Cell" . + . + . + . + . + . diff --git a/python/tests/fixtures/conformance/goldens/cybed_license.csv b/python/tests/fixtures/conformance/goldens/cybed_license.csv new file mode 100644 index 0000000..1249e9a --- /dev/null +++ b/python/tests/fixtures/conformance/goldens/cybed_license.csv @@ -0,0 +1,16 @@ +layer,slug,framework_name,license_short,license,attribution,public_redistribution,terms_url,granted,verified +framework,ccssf-2022,CCSSF 2022,"Government of Canada, with permission","The Canadian Cyber Security Skills Framework (ITSM.00.039) carries no licence notice of any kind: a sweep of the document finds no copyright line, no Creative Commons statement, no Open Government Licence reference and no all-rights-reserved statement. It carries only the Government of Canada publication identifiers (ISBN 978-0-660-46231-8, Cat. No. D97-4/00-039-2022E-PDF). The document is marked ""TLP:CLEAR"" and ""UNCLASSIFIED / NON CLASSIFIE"" on every page; both are disclosure markings rather than copyright licences. The Canadian Centre for Cyber Security gave cybedtools written permission on 2026-09-21 for full-text publication with attribution.","Canadian Centre for Cyber Security, The Canadian Cyber Security Skills Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada. Used with permission of the Canadian Centre for Cyber Security.",full_with_attribution,https://www.cyber.gc.ca/en/publications,TRUE,2026-09-21 +framework,csec2017-v1,ACM/IEEE CSEC2017,All rights reserved; educational use,"From the CSEC2017 Curricular Guidelines PDF's own copyright page: ""Copyright (c) 2017 by ACM, IEEE, AIS, IFIP / ALL RIGHTS RESERVED / Copyright and Reprint Permissions: Permission is granted to use these curricular guidelines for the development of educational materials and programs. Other use requires specific permission."" (ISBN 978-1-4503-5278-9, DOI 10.1145/3184594.) The grant is limited by purpose rather than by who may use it, and it does not extend to republishing the guidelines themselves, so cybedtools publishes Knowledge Area names, counts and alignment scores and no statement text.",,structure_only,https://cybered.acm.org/,FALSE,2026-09-18 +framework,csta-2017,CSTA K-12 CS (Rev 2017),CC BY-NC-SA 4.0,"The ingested XLSX carries no licence statement, so the label is read from CSTA's 2017-edition publication page: ""These Standards are licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License (CC BY-NC-SA 4.0)."" The share-alike term propagates: any file containing CSTA-derived content must itself go out under CC BY-NC-SA 4.0, which is why cybedtools publishes one graph file per framework rather than one combined file.","Computer Science Teachers Association (2017). CSTA K-12 Computer Science Standards, Revised 2017. Retrieved from https://csteachers.org/k12standards/.",full_with_attribution,https://creativecommons.org/licenses/by-nc-sa/4.0/,FALSE,2026-09-18 +framework,csta-2026,CSTA PK-12 CS (2026),CC BY-NC-SA 4.0,"From the 2026 CSTA PK-12 Computer Science Standards PDF's own licence page (p. iv): ""These Standards are licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. Accordingly, individuals and organizations are free to download, print, share, and adapt the materials in whole or in part, as long as they provide proper attribution, use for non-commercial purposes, and share contributions or derivations under the same license."" Every page footer repeats ""CC BY-NC-SA 4.0"". cybedtools ingests the JSON behind CSTA's interactive Standards Explorer, which carries the same standard text and is published by CSTA, so it is treated under the same terms. The share-alike term propagates to any file containing CSTA-derived content. The attribution below is the PDF's own suggested citation and DOI, with the en dash in PK-12 rendered as a hyphen.",Computer Science Teachers Association. (2026). 2026 CSTA PK-12 computer science standards. https://csteachers.org/pk12standards/ DOI: https://doi.org/10.1145/3820482,full_with_attribution,https://creativecommons.org/licenses/by-nc-sa/4.0/,FALSE,2026-09-21 +code,cybedtools,cybedtools (R package code),MIT,"LICENSE.md, the package's own licence file: ""MIT License / Copyright (c) 2026 Ryan Straight / Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ""Software""), to deal in the Software without restriction..."" The MIT licence covers the code in this repository only. It does not reach any framework's source text, which carries the framework's own terms.",Copyright (c) 2026 Ryan Straight. MIT License.,unrestricted,https://github.com/ryanstraight/cybedtools/blob/main/LICENSE.md,FALSE,2026-09-19 +framework,cyberorg-k12-v1.0,Cyber.org K-12 v1.0,CC BY-NC 4.0,"From the K-12 Cybersecurity Learning Standards PDF's own version-control page: ""K-12 Cybersecurity Learning Standards (c) 2021 by Cyber Innovation Center & CYBER.ORG is licensed under Creative Commons Attribution-NonCommercial 4.0 International"". The same page carries a separate and unconditional grant alongside the licence: ""Authorization to reproduce this report in whole or in part is granted."" The cyber.org/standards page itself states no terms, so the PDF is the only source.",K-12 Cybersecurity Learning Standards. (2021). Retrieved from https://cyber.org/standards.,full_with_attribution,https://creativecommons.org/licenses/by-nc/4.0/,FALSE,2026-09-18 +framework,cybok-v1.1.0,CyBOK v1.1.0,OGL v3.0,"From the copyright page of the Introduction to CyBOK Knowledge Area, Version 1.1.0 (July 2021): ""© Crown Copyright, The National Cyber Security Centre 2021. This information is licensed under the Open Government Licence v3.0."" The same page prescribes the attribution below. The A-to-Z Indicative Material document and each Knowledge Tree carry the same Crown Copyright and Open Government Licence v3.0 notice. The Open Government Licence v3.0 permits copying, publishing, adapting and commercial use on the condition of attribution.","CyBOK © Crown Copyright, The National Cyber Security Centre 2021, licensed under the Open Government Licence: http://www.nationalarchives.gov.uk/doc/open-government-licence/.",full_with_attribution,https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/,FALSE,2026-09-21 +framework,cyqual-v1.2.0,CyQUAL 1.2.0,"Open data, attribution required","The open-data JSON export carries no rights field of any kind. The CyQUAL team at Masaryk University confirmed in writing on 2026-09-16 that the framework is published as open data and publicly available, that a derived RDF/JSON-LD graph may be built from the export, and that the only condition is attribution to CyQUAL and to Masaryk University.","CyQUAL, the Czech national cybersecurity qualifications framework, developed at Masaryk University. Open data, version 1.2.0, https://platform.cyqual.cz/.",full_with_attribution,https://platform.cyqual.cz/en,TRUE,2026-09-18 +framework,dcwf-v5.1,DCWF v5.1,"US Government work, no US copyright","The DoD Cyber Workforce Framework Work Role Tool v5.1 workbook carries no rights or distribution statement in any of its 79 sheets, and the DoD download pages sit behind authentication, so no download-page statement could be read as of 2026-09-18. The governing position is therefore inferred rather than quoted: a work of the US Government prepared by DoD personnel is not subject to copyright in the United States under 17 U.S.C. 105. Nine work roles carry a ""(CUI)"" tag in the workbook's role index; review those sheets before any public deposit of DCWF-derived content.",,unrestricted,https://www.cyberworkforce.mil/Department-Cyber-Workforce-Framework/,FALSE,2026-09-18 +framework,digcomp-3.0,DigComp 3.0,CC BY 4.0,"From the copyright notice distributed with the official DigComp 3.0 data supplement (JRC144121, ISBN 978-92-68-32677-0): ""(c) European Union, 1995-2026 / The Commission's reuse policy is implemented by the Commission Decision of 12 December 2011 on the reuse of Commission documents. Any copyright and/or sui generis right on the dataset is licensed under the Creative Commons Attribution 4.0 International (CC BY 4.0) licence. Reuse is allowed provided appropriate credit is given and any changes are indicated."" Also confirmed on the JRC Data Catalogue record (DOI 10.2905/JRC.FR75K8R) and the DigComp 3.0 resources page, which additionally excludes the European Commission logo from reuse; cybedtools reproduces no logo.","Cosgrove, J. and Cachia, R., DigComp 3.0: The Digital Competence Framework for Citizens, EUR 40491, Publications Office of the European Union, Luxembourg, 2025, ISBN 978-92-68-32677-0, doi:10.2760/0001149. Dataset: doi:10.2905/JRC.FR75K8R.",full_with_attribution,https://creativecommons.org/licenses/by/4.0/,FALSE,2026-09-21 +framework,ecsf-v1,ECSF v1,CC BY 4.0 (report); data files unmarked,"Two things are true. The ECSF Role Profiles report PDF (September 2022, ISBN 978-92-9204-584-5, DOI 10.2824/859537) carries its own copyright notice: ""(c) European Union Agency for Cybersecurity (ENISA), 2022 / This publication is licenced under CC-BY 4.0 ... the reuse of this document is authorised under the Creative Commons Attribution 4.0 International (CC BY 4.0) licence"", and its legal notice adds ""All references to it or its use as a whole or partially must contain ENISA as its source."" The JSON and XLSX files cybedtools ingests carry no notice of any kind, and rest instead on ENISA's site-wide legal notice: ""Reproduction of ENISA material published on this website is authorized, provided the source is acknowledged, unless it is stated otherwise."" ENISA prescribes no citation wording for the report or the data files, so the attribution below is the package's own and follows what the CC BY 4.0 licence requires.","European Union Agency for Cybersecurity (ENISA), European Cybersecurity Skills Framework (ECSF) Role Profiles, September 2022, ISBN 978-92-9204-584-5, DOI 10.2824/859537. (c) ENISA 2022, licensed under CC BY 4.0. Source: https://www.enisa.europa.eu/publications/european-cybersecurity-skills-framework-role-profiles",full_with_attribution,https://www.enisa.europa.eu/publications/european-cybersecurity-skills-framework-role-profiles,FALSE,2026-09-19 +framework,nice-v2,NICE v2.2.0,US public domain + NIST worldwide grant,"The ingested CPRT JSON carries no rights statement, so NIST's published statements govern. NIST, ""Copyright, Fair Use, and Licensing Statements for SRD, Data, Software and Technical Series Publications"", Technical Series section: ""Works authored by NIST employees are not subject to Copyright protection within the United States; foreign rights are reserved. To the extent NIST may assert rights outside of the United States, the public is granted the non-exclusive, perpetual, paid-up, royalty-free, worldwide right to reprint works in all formats including print, electronically, and online, and in all subsequent editions, and derivative works."" NIST's copyright page adds that ""Use of appropriate byline/photo/image credits is requested.""",,unrestricted,https://www.nist.gov/open/copyright-fair-use-and-licensing-statements-srd-data-software-and-technical-series-publications,FALSE,2026-09-18 +framework,otccf-v1.1,OTCCF v1.1,CSA copyright; non-commercial academic,"""(c) Cyber Security Agency of Singapore"" appears on every page of the Operational Technology Cybersecurity Competency Framework PDF, and the document carries no licence beyond that copyright line. The Cyber Security Agency of Singapore granted cybedtools written permission on 2026-09-15 to reference and integrate the OTCCF's structure for non-commercial, academic and research purposes, on two conditions: the attribution wording below word for word, and that the ingestion scripts and structural mappings not misrepresent or alter the intent of the OTCCF's job roles, skills, or competency mappings as published by CSA. The grant covers structure only: roles, skill titles, categories, proficiency levels and role-to-skill mappings, not statement text. The document names Mercer Singapore as a joint developer; whether Mercer holds any interest CSA's grant does not reach is unresolved.","Derived from the Operational Technology Cybersecurity Competency Framework (OTCCF), published by the Cyber Security Agency of Singapore (CSA). Available at: https://www.csa.gov.sg/resources/publications/operational-technology-cybersecurity-competency-framework--otccf-/",structure_only,https://www.csa.gov.sg/resources/publications/operational-technology-cybersecurity-competency-framework--otccf-/,TRUE,2026-09-18 +framework,scywf-1.5,SCyWF 1.5,NCA written permission,"The Saudi Cybersecurity Workforce Framework (SCyWF – 1.5: 2026) PDF carries no copyright line and no licence notice. Its only markings are ""TLP: Clear"" and ""Document Classification: Public"", which are disclosure markings rather than licences. The National Cybersecurity Authority (NCA) of the Kingdom of Saudi Arabia granted cybedtools written permission on 2026-09-20 to ingest and redistribute the framework's content, on three conditions. (1) NCA is cited as issuing body and source, with a link to the official SCyWF page. (2) Everything taken from the document (identifiers, titles, statement text) is carried verbatim, with no normalised labels, paraphrase, in-house translation or composed text presented as NCA content. (3) Anything cybedtools derives, such as hierarchy edges or cross-framework mappings, is marked as derived and not presented as NCA content. NCA prescribed no attribution wording, so the attribution below is composed to meet the first condition. The grant named an earlier edition (SCyWF - 1 : 2020) that NCA no longer publishes. The current edition is ingested by owner decision. The document's own disclaimer makes its Arabic version binding for matters of meaning and interpretation. cybedtools ingests the English version.","The Saudi Cybersecurity Workforce Framework (SCyWF – 1.5: 2026). Issued by the National Cybersecurity Authority (NCA), Kingdom of Saudi Arabia. Source: https://nca.gov.sa/en/pages/scywf.html",full_with_attribution,https://nca.gov.sa/en/pages/scywf.html,TRUE,2026-09-21 +framework,sfia-9,SFIA 9,SFIA Foundation licence required,"SFIA Foundation, ""Using and licensing SFIA"": ""Note: All use of SFIA is under licence."" / ""The SFIA Framework, guidance and support assets are the intellectual property of the SFIA Foundation."" / ""Copying of this material is prohibited unless authorised in writing or under a valid SFIA licence obtained from the SFIA Foundation."" The SFIA General Terms and Conditions define the protected material as ""The concept, content and structure of SFIA along with content from the SFIA website"", and name ""a skills database containing SFIA information"" as a royalty-bearing dependent product, so there is no structure-versus-text carve-out. The third-party structural extract cybedtools reads carries no licence file of any kind and holds no SFIA licence it could pass on. SFIA content in cybedtools is local-analysis only; nothing beyond aggregate counts is published.",,local_only,https://sfia-online.org/en/about-sfia/licensing-sfia/using-and-licensing-sfia,FALSE,2026-09-18 diff --git a/python/tests/fixtures/conformance/goldens/element_framework_bindings.csv b/python/tests/fixtures/conformance/goldens/element_framework_bindings.csv new file mode 100644 index 0000000..1d2d065 --- /dev/null +++ b/python/tests/fixtures/conformance/goldens/element_framework_bindings.csv @@ -0,0 +1,8 @@ +element,framework,framework_name,framework_slug +https://w3id.org/cybed/ontology#element/fixture-el-role1,https://w3id.org/cybed/ontology#framework/fixture-wf1,Fixture Workforce Framework One,fixture-wf1 +https://w3id.org/cybed/ontology#element/fixture-el-role2,https://w3id.org/cybed/ontology#framework/fixture-wf1,Fixture Workforce Framework One,fixture-wf1 +https://w3id.org/cybed/ontology#element/fixture-el-role3,https://w3id.org/cybed/ontology#framework/fixture-wf2,Fixture Workforce Framework Two,fixture-wf2 +https://w3id.org/cybed/ontology#element/fixture-el-role4,https://w3id.org/cybed/ontology#framework/fixture-wf2,Fixture Workforce Framework Two,fixture-wf2 +https://w3id.org/cybed/ontology#element/fixture-el-unit5,https://w3id.org/cybed/ontology#framework/fixture-ped1,Fixture Pedagogy Framework One,fixture-ped1 +https://w3id.org/cybed/ontology#element/fixture-el-unit5.example.1,https://w3id.org/cybed/ontology#framework/fixture-ped1,Fixture Pedagogy Framework One,fixture-ped1 +https://w3id.org/cybed/ontology#element/fixture-el-unit5.sub.1,https://w3id.org/cybed/ontology#framework/fixture-ped1,Fixture Pedagogy Framework One,fixture-ped1 diff --git a/python/tests/fixtures/conformance/goldens/element_text.csv b/python/tests/fixtures/conformance/goldens/element_text.csv new file mode 100644 index 0000000..aacc6cf --- /dev/null +++ b/python/tests/fixtures/conformance/goldens/element_text.csv @@ -0,0 +1,8 @@ +element,text +https://w3id.org/cybed/ontology#element/fixture-el-role1,Monitors network traffic for security incidents and analyzes alerts +https://w3id.org/cybed/ontology#element/fixture-el-role2,Configures firewalls and defends the network perimeter +https://w3id.org/cybed/ontology#element/fixture-el-role3,Monitors network traffic for incidents and coordinates incident response +https://w3id.org/cybed/ontology#element/fixture-el-role4,Hunts for adversaries hiding in network traffic and endpoint logs +https://w3id.org/cybed/ontology#element/fixture-el-unit5,Protéger les données personnelles et respecter la vie privée +https://w3id.org/cybed/ontology#element/fixture-el-unit5.example.1,Clarification statement: includes GDPR-relevant scenarios +https://w3id.org/cybed/ontology#element/fixture-el-unit5.sub.1,chiffrement et contrôle d'accès diff --git a/python/tests/fixtures/conformance/goldens/example_framework_bindings.csv b/python/tests/fixtures/conformance/goldens/example_framework_bindings.csv new file mode 100644 index 0000000..888b9cb --- /dev/null +++ b/python/tests/fixtures/conformance/goldens/example_framework_bindings.csv @@ -0,0 +1,2 @@ +example,framework,framework_name,framework_slug +https://w3id.org/cybed/ontology#element/fixture-el-unit5.example.1,https://w3id.org/cybed/ontology#framework/fixture-ped1,Fixture Pedagogy Framework One,fixture-ped1 diff --git a/python/tests/fixtures/conformance/goldens/framework_metadata.csv b/python/tests/fixtures/conformance/goldens/framework_metadata.csv new file mode 100644 index 0000000..c20de1e --- /dev/null +++ b/python/tests/fixtures/conformance/goldens/framework_metadata.csv @@ -0,0 +1,4 @@ +framework,name,jurisdiction,sector,specificity,framework_slug +https://w3id.org/cybed/ontology#framework/fixture-ped1,Fixture Pedagogy Framework One,global,education,cybersecurity-specific,fixture-ped1 +https://w3id.org/cybed/ontology#framework/fixture-wf1,Fixture Workforce Framework One,US,civilian,cybersecurity-specific,fixture-wf1 +https://w3id.org/cybed/ontology#framework/fixture-wf2,Fixture Workforce Framework Two,EU,civilian,cybersecurity-specific,fixture-wf2 diff --git a/python/tests/fixtures/conformance/goldens/framework_similarity.csv b/python/tests/fixtures/conformance/goldens/framework_similarity.csv new file mode 100644 index 0000000..a55964e --- /dev/null +++ b/python/tests/fixtures/conformance/goldens/framework_similarity.csv @@ -0,0 +1,5 @@ +from_unit,to_unit,score,strength,rank +https://w3id.org/cybed/ontology#role/fixture-role-1,https://w3id.org/cybed/ontology#role/fixture-role-4,0.4666666667,strong,1 +https://w3id.org/cybed/ontology#role/fixture-role-1,https://w3id.org/cybed/ontology#role/fixture-role-3,0.3333333333,strong,2 +https://w3id.org/cybed/ontology#role/fixture-role-2,https://w3id.org/cybed/ontology#role/fixture-role-4,0.3684210526,strong,1 +https://w3id.org/cybed/ontology#role/fixture-role-2,https://w3id.org/cybed/ontology#role/fixture-role-3,0.25,moderate,2 diff --git a/python/tests/fixtures/conformance/goldens/framework_summary.csv b/python/tests/fixtures/conformance/goldens/framework_summary.csv new file mode 100644 index 0000000..fcbc5a8 --- /dev/null +++ b/python/tests/fixtures/conformance/goldens/framework_summary.csv @@ -0,0 +1,15 @@ +framework_slug,framework_name,framework_type,jurisdiction,organizing_unit_count,role_count,element_count_strict,subpoint_count,example_count,element_count_with_examples,unit_relation_count,elements_per_organizing_unit_strict,elements_per_organizing_unit_with_examples,elements_per_role_strict,elements_per_role_with_examples,license +ccssf-2022,CCSSF 2022,workforce,CA,59,59,1148,197,0,1345,0,19.5,22.8,19.5,22.8,"Government of Canada, with permission" +csec2017-v1,ACM/IEEE CSEC2017,pedagogy,global,8,,38,2,0,40,0,4.8,5,,,All rights reserved; educational use +csta-2017,CSTA K-12 CS (Rev 2017),pedagogy,US,25,,120,24,114,258,0,4.8,10.3,,,CC BY-NC-SA 4.0 +csta-2026,CSTA PK-12 CS (2026),pedagogy,US,53,,331,0,652,983,0,6.2,18.5,,,CC BY-NC-SA 4.0 +cyberorg-k12-v1.0,Cyber.org K-12 v1.0,pedagogy,US,116,,123,0,369,492,0,1.1,4.2,,,CC BY-NC 4.0 +cybok-v1.1.0,CyBOK v1.1.0,pedagogy,UK,21,,119,477,0,596,0,5.7,28.4,,,OGL v3.0 +cyqual-v1.2.0,CyQUAL 1.2.0,workforce,CZ,161,102,2488,852,0,3340,0,15.5,20.7,22.9,30.9,"Open data, attribution required" +dcwf-v5.1,DCWF v5.1,workforce,US,74,74,2945,1107,0,4052,0,39.8,54.8,39.8,54.8,"US Government work, no US copyright" +digcomp-3.0,DigComp 3.0,pedagogy,EU,26,,362,0,522,884,0,13.9,34,,,CC BY 4.0 +ecsf-v1,ECSF v1,workforce,EU,12,12,374,16,0,390,0,31.2,32.5,31.2,32.5,CC BY 4.0 (report); data files unmarked +nice-v2,NICE v2.2.0,workforce,US,53,42,2211,14,0,2225,0,41.7,42,44.4,44.7,US public domain + NIST worldwide grant +otccf-v1.1,OTCCF v1.1,workforce,SG,61,15,1612,0,0,1612,310,26.4,26.4,18,18,CSA copyright; non-commercial academic +scywf-1.5,SCyWF 1.5,workforce,SA,81,40,1419,0,0,1419,0,17.5,17.5,35.5,35.5,NCA written permission +sfia-9,SFIA 9,workforce,global,147,,672,149,0,821,0,4.6,5.6,,,SFIA Foundation licence required diff --git a/python/tests/fixtures/conformance/goldens/organizing_unit_framework_bindings.csv b/python/tests/fixtures/conformance/goldens/organizing_unit_framework_bindings.csv new file mode 100644 index 0000000..3bd0366 --- /dev/null +++ b/python/tests/fixtures/conformance/goldens/organizing_unit_framework_bindings.csv @@ -0,0 +1,6 @@ +unit,framework,unit_name,framework_name,framework_slug +https://w3id.org/cybed/ontology#role/fixture-role-1,https://w3id.org/cybed/ontology#framework/fixture-wf1,Security Analyst,Fixture Workforce Framework One,fixture-wf1 +https://w3id.org/cybed/ontology#role/fixture-role-2,https://w3id.org/cybed/ontology#framework/fixture-wf1,Network Defender,Fixture Workforce Framework One,fixture-wf1 +https://w3id.org/cybed/ontology#role/fixture-role-3,https://w3id.org/cybed/ontology#framework/fixture-wf2,Incident Handler,Fixture Workforce Framework Two,fixture-wf2 +https://w3id.org/cybed/ontology#role/fixture-role-4,https://w3id.org/cybed/ontology#framework/fixture-wf2,Threat Hunter,Fixture Workforce Framework Two,fixture-wf2 +https://w3id.org/cybed/ontology#unit/fixture-unit-5,https://w3id.org/cybed/ontology#framework/fixture-ped1,Grade Band Data Protection Cell,Fixture Pedagogy Framework One,fixture-ped1 diff --git a/python/tests/fixtures/conformance/goldens/role_framework_bindings.csv b/python/tests/fixtures/conformance/goldens/role_framework_bindings.csv new file mode 100644 index 0000000..ee52d93 --- /dev/null +++ b/python/tests/fixtures/conformance/goldens/role_framework_bindings.csv @@ -0,0 +1,5 @@ +role,framework,role_name,framework_name,framework_slug +https://w3id.org/cybed/ontology#role/fixture-role-1,https://w3id.org/cybed/ontology#framework/fixture-wf1,Security Analyst,Fixture Workforce Framework One,fixture-wf1 +https://w3id.org/cybed/ontology#role/fixture-role-2,https://w3id.org/cybed/ontology#framework/fixture-wf1,Network Defender,Fixture Workforce Framework One,fixture-wf1 +https://w3id.org/cybed/ontology#role/fixture-role-3,https://w3id.org/cybed/ontology#framework/fixture-wf2,Incident Handler,Fixture Workforce Framework Two,fixture-wf2 +https://w3id.org/cybed/ontology#role/fixture-role-4,https://w3id.org/cybed/ontology#framework/fixture-wf2,Threat Hunter,Fixture Workforce Framework Two,fixture-wf2 diff --git a/python/tests/fixtures/conformance/goldens/subpoint_framework_bindings.csv b/python/tests/fixtures/conformance/goldens/subpoint_framework_bindings.csv new file mode 100644 index 0000000..08d891d --- /dev/null +++ b/python/tests/fixtures/conformance/goldens/subpoint_framework_bindings.csv @@ -0,0 +1,2 @@ +subpoint,framework,framework_name,framework_slug +https://w3id.org/cybed/ontology#element/fixture-el-unit5.sub.1,https://w3id.org/cybed/ontology#framework/fixture-ped1,Fixture Pedagogy Framework One,fixture-ped1 diff --git a/python/tests/fixtures/conformance/goldens/unit_element_bindings.csv b/python/tests/fixtures/conformance/goldens/unit_element_bindings.csv new file mode 100644 index 0000000..1d94ce0 --- /dev/null +++ b/python/tests/fixtures/conformance/goldens/unit_element_bindings.csv @@ -0,0 +1,9 @@ +role,element,framework_slug +https://w3id.org/cybed/ontology#role/fixture-role-1,https://w3id.org/cybed/ontology#element/fixture-el-role1,fixture-wf1 +https://w3id.org/cybed/ontology#role/fixture-role-2,https://w3id.org/cybed/ontology#element/fixture-el-role1,fixture-wf1 +https://w3id.org/cybed/ontology#role/fixture-role-2,https://w3id.org/cybed/ontology#element/fixture-el-role2,fixture-wf1 +https://w3id.org/cybed/ontology#role/fixture-role-3,https://w3id.org/cybed/ontology#element/fixture-el-role3,fixture-wf2 +https://w3id.org/cybed/ontology#role/fixture-role-4,https://w3id.org/cybed/ontology#element/fixture-el-role1,fixture-wf2 +https://w3id.org/cybed/ontology#role/fixture-role-4,https://w3id.org/cybed/ontology#element/fixture-el-role4,fixture-wf2 +https://w3id.org/cybed/ontology#unit/fixture-unit-5,https://w3id.org/cybed/ontology#element/fixture-el-unit5,fixture-ped1 +https://w3id.org/cybed/ontology#unit/fixture-unit-5,https://w3id.org/cybed/ontology#element/fixture-el-unit5.sub.1,fixture-ped1 diff --git a/python/tests/fixtures/conformance/goldens/unit_relation_bindings.csv b/python/tests/fixtures/conformance/goldens/unit_relation_bindings.csv new file mode 100644 index 0000000..722168d --- /dev/null +++ b/python/tests/fixtures/conformance/goldens/unit_relation_bindings.csv @@ -0,0 +1,2 @@ +from_unit,relation,to_unit,framework_slug +https://w3id.org/cybed/ontology#role/fixture-role-1,requires,https://w3id.org/cybed/ontology#role/fixture-role-3,fixture-wf1 diff --git a/python/tests/fixtures/conformance/mock-release/1.0.0/fixture-ped1.nt.gz b/python/tests/fixtures/conformance/mock-release/1.0.0/fixture-ped1.nt.gz new file mode 100644 index 0000000..b01c9a5 Binary files /dev/null and b/python/tests/fixtures/conformance/mock-release/1.0.0/fixture-ped1.nt.gz differ diff --git a/python/tests/fixtures/conformance/mock-release/1.0.0/fixture-wf1.nt.gz b/python/tests/fixtures/conformance/mock-release/1.0.0/fixture-wf1.nt.gz new file mode 100644 index 0000000..a27df20 Binary files /dev/null and b/python/tests/fixtures/conformance/mock-release/1.0.0/fixture-wf1.nt.gz differ diff --git a/python/tests/fixtures/conformance/mock-release/1.0.0/fixture-wf2.nt.gz b/python/tests/fixtures/conformance/mock-release/1.0.0/fixture-wf2.nt.gz new file mode 100644 index 0000000..2299565 Binary files /dev/null and b/python/tests/fixtures/conformance/mock-release/1.0.0/fixture-wf2.nt.gz differ diff --git a/python/tests/fixtures/conformance/mock-release/1.0.0/manifest.json b/python/tests/fixtures/conformance/mock-release/1.0.0/manifest.json new file mode 100644 index 0000000..63d0bb2 --- /dev/null +++ b/python/tests/fixtures/conformance/mock-release/1.0.0/manifest.json @@ -0,0 +1,34 @@ +{ + "manifest_version": "1", + "release_version": "1.0.0", + "release_date": "2026-09-21", + "files": [ + { + "slug": "fixture-wf1", + "file": "fixture-wf1.nt.gz", + "triples": 5, + "bytes": 215, + "bytes_uncompressed": 618, + "sha256": "aafb41e8847cddec9d294f2c5ed753d962c35e2e9533a2d6a32bf405bf4c90a4", + "sha256_uncompressed": "f383dd4be844597084ba9f2b241c2a0b210bef1660ddd6dc9c35e91705d0e6b4" + }, + { + "slug": "fixture-wf2", + "file": "fixture-wf2.nt.gz", + "triples": 5, + "bytes": 215, + "bytes_uncompressed": 618, + "sha256": "9a7bc7b6b81feb152a35bdacb145985478801f0dc1b2990d545c2c2105fe9a87", + "sha256_uncompressed": "66b2ca5ca2cf8ae64a3a0b15c8cf4f010d518ab1fe0b7ec98275d0bd0e2abc25" + }, + { + "slug": "fixture-ped1", + "file": "fixture-ped1.nt.gz", + "triples": 5, + "bytes": 216, + "bytes_uncompressed": 627, + "sha256": "56558a4d6c09670e4f9701c786eceefbde79c21d24a1727f417f1accc9f041af", + "sha256_uncompressed": "70b15bd3a3b0cb0a8f1c693642fa85ab0f8d7d5d149bf808715658bf0049e37d" + } + ] +} diff --git a/python/tests/test_conformance.py b/python/tests/test_conformance.py new file mode 100644 index 0000000..8d9707e --- /dev/null +++ b/python/tests/test_conformance.py @@ -0,0 +1,74 @@ +"""Conformance harness smoke tests plus placeholders for not-yet-implemented functions. + +See inst/conformance/README.md (the repository root's conformance contract) +and cybedtools._conformance for the shared loading/comparison helpers. +Function-specific conformance assertions for framework_summary()/ +cybed_license() live in test_data.py; this module covers the harness itself +and reserves named slots for the query-graph functions a later change adds +(framework_metadata(), unit_element_bindings(), unit_relation_bindings(), +framework_similarity()). +""" + +from __future__ import annotations + +import filecmp + +import pytest + +from cybedtools._conformance import conformance_dir, load_golden + + +def test_conformance_dir_is_found() -> None: + """The packaged fixture copy (or the repo-root fallback) resolves to a real directory.""" + assert conformance_dir().is_dir() + + +def test_load_golden_framework_summary() -> None: + """The golden loader reads framework_summary.csv with a header and rows.""" + golden = load_golden("framework_summary") + assert list(golden.columns)[0] == "framework_slug" + assert len(golden) > 0 + + +def test_fixture_copy_matches_source() -> None: + """tests/fixtures/conformance stays byte-identical to the repo-root inst/conformance copy. + + Only meaningful inside a full worktree checkout (the repo-root copy is + absent from an extracted sdist, where this test is skipped rather than + failed). + """ + packaged = conformance_dir() + repo_root_source = packaged.parents[3] / "inst" / "conformance" + if not repo_root_source.is_dir(): + pytest.skip("Running outside a full worktree checkout; no repo-root copy to compare.") + if packaged == repo_root_source: + pytest.skip("conformance_dir() resolved directly to the repo-root copy.") + + for name in ( + "framework_summary.csv", + "cybed_license.csv", + "framework_metadata.csv", + "unit_element_bindings.csv", + "unit_relation_bindings.csv", + "framework_similarity.csv", + "role_framework_bindings.csv", + "organizing_unit_framework_bindings.csv", + "element_framework_bindings.csv", + "example_framework_bindings.csv", + "subpoint_framework_bindings.csv", + "element_text.csv", + ): + packaged_file = packaged / "goldens" / name + source_file = repo_root_source / "goldens" / name + assert filecmp.cmp(packaged_file, source_file, shallow=False), ( + f"{packaged_file} has drifted from {source_file}. Re-copy it " + "(see python/tools/export_r_data.R)." + ) + + +# framework_metadata(), unit_element_bindings(), and unit_relation_bindings() +# conformance now lives in test_queries.py, exercised against fixture.nt + +# their respective goldens. +# +# framework_similarity() conformance now lives in test_similarity.py, exercised +# against the shipped fixture.nt + goldens/framework_similarity.csv. diff --git a/python/tests/test_data.py b/python/tests/test_data.py new file mode 100644 index 0000000..c675fd8 --- /dev/null +++ b/python/tests/test_data.py @@ -0,0 +1,48 @@ +"""Conformance tests for framework_summary(), framework_licenses(), cybed_license().""" + +from __future__ import annotations + +import pytest + +from cybedtools._conformance import assert_matches_golden +from cybedtools.data import ( + CybedtoolsFrameworkNotFoundError, + CybedtoolsScalarInputError, + cybed_license, + framework_licenses, + framework_summary, +) + + +def test_framework_summary_matches_golden() -> None: + """framework_summary() matches inst/conformance/goldens/framework_summary.csv.""" + assert_matches_golden(framework_summary(), "framework_summary", sort_by=["framework_slug"]) + + +def test_cybed_license_no_arg_matches_golden() -> None: + """cybed_license() with no argument matches inst/conformance/goldens/cybed_license.csv.""" + assert_matches_golden(cybed_license(), "cybed_license", sort_by=["slug"]) + + +def test_framework_licenses_is_the_same_table_as_cybed_license() -> None: + """framework_licenses() is exactly cybed_license()'s no-argument result.""" + assert_matches_golden(framework_licenses(), "cybed_license", sort_by=["slug"]) + + +def test_cybed_license_single_slug_returns_one_row() -> None: + """cybed_license(slug) returns exactly the matching row.""" + row = cybed_license("nice-v2") + assert len(row) == 1 + assert row["slug"].iloc[0] == "nice-v2" + + +def test_cybed_license_unknown_slug_raises() -> None: + """An unknown slug raises CybedtoolsFrameworkNotFoundError, not a silent empty frame.""" + with pytest.raises(CybedtoolsFrameworkNotFoundError): + cybed_license("not-a-real-framework") + + +def test_cybed_license_non_string_slug_raises() -> None: + """A non-string, non-None slug raises CybedtoolsScalarInputError.""" + with pytest.raises(CybedtoolsScalarInputError): + cybed_license(123) # type: ignore[arg-type] diff --git a/python/tests/test_fetch.py b/python/tests/test_fetch.py new file mode 100644 index 0000000..27c7cbf --- /dev/null +++ b/python/tests/test_fetch.py @@ -0,0 +1,103 @@ +"""Tests for cybed_fetch() / load_graph() against the committed mock release. + +No test in this module reaches the network: cybedtools.fetch.set_release_url +points at inst/conformance/mock-release (a copy of which is checked into +tests/fixtures/conformance/) via a file:// URL, matching the R test suite's +`options(cybedtools.release_url = ...)` pattern documented in +inst/conformance/README.md. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import cybedtools +from cybedtools._conformance import conformance_dir +from cybedtools.fetch import ( + CybedtoolsFrameworkNotFoundError, + CybedtoolsHashMismatchError, + cybed_fetch, + set_release_url, +) + + +@pytest.fixture +def mock_release_url(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> str: + """Point cybedtools at the committed mock release via a file:// URL. + + Also redirects the user cache directory into a temp path (via + platformdirs' env var override) so a run never touches the real machine + cache and each test starts from a clean cache. + """ + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache")) + monkeypatch.setattr( + "cybedtools.fetch.platformdirs.user_cache_dir", + lambda name: str(tmp_path / "cache" / name), + ) + mock_release_dir = conformance_dir() / "mock-release" + url = mock_release_dir.resolve().as_uri() + set_release_url(url) + yield url + set_release_url(None) + + +def test_cybed_fetch_all_frameworks(mock_release_url: str) -> None: + """With frameworks=None, every framework in the mock manifest is fetched.""" + result = cybed_fetch(version="1.0.0") + assert set(result["framework_slug"]) == {"fixture-wf1", "fixture-wf2", "fixture-ped1"} + assert result["sha256_verified"].all() + for path_str in result["path"]: + assert Path(path_str).is_file() + + +def test_cybed_fetch_one_framework(mock_release_url: str) -> None: + """A single requested slug fetches only that framework.""" + result = cybed_fetch(frameworks=["fixture-wf1"], version="1.0.0") + assert list(result["framework_slug"]) == ["fixture-wf1"] + + +def test_cybed_fetch_unknown_slug_raises(mock_release_url: str) -> None: + """An unrecognized slug raises rather than silently skipping.""" + with pytest.raises(CybedtoolsFrameworkNotFoundError): + cybed_fetch(frameworks=["not-a-real-framework"], version="1.0.0") + + +def test_cybed_fetch_is_cached_on_second_call(mock_release_url: str) -> None: + """A second fetch of the same framework reuses the cached, hash-verified file.""" + first = cybed_fetch(frameworks=["fixture-wf2"], version="1.0.0") + second = cybed_fetch(frameworks=["fixture-wf2"], version="1.0.0") + assert first["path"].iloc[0] == second["path"].iloc[0] + + +def test_cybed_fetch_hash_mismatch_deletes_file( + mock_release_url: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A corrupted manifest hash raises and does not leave a cached file behind.""" + import json + import shutil + + corrupt_release = tmp_path / "corrupt-release" / "1.0.0" + shutil.copytree(conformance_dir() / "mock-release" / "1.0.0", corrupt_release) + manifest_path = corrupt_release / "manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["files"][0]["sha256"] = "0" * 64 + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + set_release_url(corrupt_release.parent.resolve().as_uri()) + bad_slug = manifest["files"][0]["slug"] + with pytest.raises(CybedtoolsHashMismatchError): + cybed_fetch(frameworks=[bad_slug], version="1.0.0") + + cache_root = Path(cybedtools.fetch._cache_dir()) / "1.0.0" # noqa: SLF001 + assert not any(cache_root.glob(f"{bad_slug}*")) + + +def test_load_graph_returns_populated_rdflib_graph(mock_release_url: str) -> None: + """load_graph() parses the fetched framework files into one rdflib.Graph.""" + import rdflib + + graph = cybedtools.load_graph(frameworks=["fixture-wf1"], version="1.0.0") + assert isinstance(graph, rdflib.Graph) + assert len(graph) > 0 diff --git a/python/tests/test_import.py b/python/tests/test_import.py index e189739..0a00ba4 100644 --- a/python/tests/test_import.py +++ b/python/tests/test_import.py @@ -1,8 +1,13 @@ -"""Smoke tests for the placeholder release.""" +"""Import and version smoke tests.""" -import tomllib +import sys from pathlib import Path +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + import cybedtools diff --git a/python/tests/test_queries.py b/python/tests/test_queries.py new file mode 100644 index 0000000..e81609c --- /dev/null +++ b/python/tests/test_queries.py @@ -0,0 +1,119 @@ +"""Conformance tests for the query-graph helpers in cybedtools.queries. + +Each function is run against ``tests/fixtures/conformance/fixture.nt`` and +checked byte-for-value against its golden in +``tests/fixtures/conformance/goldens/`` per the contract in +``inst/conformance/README.md``. See ``cybedtools._conformance`` for the +shared loading/comparison helpers. +""" + +from __future__ import annotations + +import warnings + +import pytest +import rdflib + +from cybedtools._conformance import assert_matches_golden, conformance_dir +from cybedtools.queries import ( + element_framework_bindings, + element_text, + example_framework_bindings, + framework_metadata, + organizing_unit_framework_bindings, + role_element_bindings, + role_framework_bindings, + subpoint_framework_bindings, + unit_element_bindings, + unit_relation_bindings, +) + + +@pytest.fixture(scope="module") +def fixture_graph() -> rdflib.Graph: + """Parse the shared conformance fixture graph once per test module.""" + graph = rdflib.Graph() + graph.parse(str(conformance_dir() / "fixture.nt"), format="nt") + return graph + + +def test_framework_metadata_matches_golden(fixture_graph: rdflib.Graph) -> None: + """framework_metadata() matches goldens/framework_metadata.csv.""" + assert_matches_golden(framework_metadata(fixture_graph), "framework_metadata", sort_by=["framework"]) + + +def test_unit_element_bindings_matches_golden(fixture_graph: rdflib.Graph) -> None: + """unit_element_bindings() matches goldens/unit_element_bindings.csv.""" + assert_matches_golden( + unit_element_bindings(fixture_graph), "unit_element_bindings", sort_by=["role", "element"] + ) + + +def test_unit_relation_bindings_matches_golden(fixture_graph: rdflib.Graph) -> None: + """unit_relation_bindings() matches goldens/unit_relation_bindings.csv.""" + assert_matches_golden( + unit_relation_bindings(fixture_graph), + "unit_relation_bindings", + sort_by=["from_unit", "to_unit", "relation"], + ) + + +def test_role_framework_bindings_matches_golden(fixture_graph: rdflib.Graph) -> None: + """role_framework_bindings() matches goldens/role_framework_bindings.csv.""" + assert_matches_golden( + role_framework_bindings(fixture_graph), "role_framework_bindings", sort_by=["role", "framework"] + ) + + +def test_organizing_unit_framework_bindings_matches_golden(fixture_graph: rdflib.Graph) -> None: + """organizing_unit_framework_bindings() matches goldens/organizing_unit_framework_bindings.csv.""" + assert_matches_golden( + organizing_unit_framework_bindings(fixture_graph), + "organizing_unit_framework_bindings", + sort_by=["unit", "framework"], + ) + + +def test_element_framework_bindings_matches_golden(fixture_graph: rdflib.Graph) -> None: + """element_framework_bindings() matches goldens/element_framework_bindings.csv.""" + assert_matches_golden( + element_framework_bindings(fixture_graph), "element_framework_bindings", sort_by=["element", "framework"] + ) + + +def test_example_framework_bindings_matches_golden(fixture_graph: rdflib.Graph) -> None: + """example_framework_bindings() matches goldens/example_framework_bindings.csv.""" + assert_matches_golden( + example_framework_bindings(fixture_graph), "example_framework_bindings", sort_by=["example", "framework"] + ) + + +def test_subpoint_framework_bindings_matches_golden(fixture_graph: rdflib.Graph) -> None: + """subpoint_framework_bindings() matches goldens/subpoint_framework_bindings.csv.""" + assert_matches_golden( + subpoint_framework_bindings(fixture_graph), "subpoint_framework_bindings", sort_by=["subpoint", "framework"] + ) + + +def test_element_text_matches_golden(fixture_graph: rdflib.Graph) -> None: + """element_text() matches goldens/element_text.csv.""" + assert_matches_golden(element_text(fixture_graph), "element_text", sort_by=["element"]) + + +def test_role_element_bindings_is_deprecated_alias(fixture_graph: rdflib.Graph) -> None: + """role_element_bindings() warns DeprecationWarning and returns unit_element_bindings()'s result.""" + with pytest.warns(DeprecationWarning, match="unit_element_bindings"): + deprecated_result = role_element_bindings(fixture_graph) + direct_result = unit_element_bindings(fixture_graph) + assert deprecated_result.equals(direct_result) + + +def test_role_element_bindings_warning_is_exactly_one() -> None: + """Calling the deprecated alias raises exactly one warning, not a cascade.""" + graph = rdflib.Graph() + graph.parse(str(conformance_dir() / "fixture.nt"), format="nt") + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + role_element_bindings(graph) + deprecation_warnings = [w for w in caught if issubclass(w.category, DeprecationWarning)] + assert len(deprecation_warnings) == 1 diff --git a/python/tests/test_similarity.py b/python/tests/test_similarity.py new file mode 100644 index 0000000..077c376 --- /dev/null +++ b/python/tests/test_similarity.py @@ -0,0 +1,125 @@ +"""Conformance and unit tests for cybedtools.similarity.""" + +from __future__ import annotations + +import rdflib + +from cybedtools._conformance import assert_matches_golden, conformance_dir +from cybedtools.similarity import _jaccard, _tokenize, framework_similarity + + +def _load_fixture_graph() -> rdflib.Graph: + graph = rdflib.Graph() + graph.parse(str(conformance_dir() / "fixture.nt"), format="nt") + return graph + + +def test_framework_similarity_matches_golden() -> None: + """framework_similarity(graph, "fixture-wf1", "fixture-wf2", 5) matches goldens/framework_similarity.csv.""" + graph = _load_fixture_graph() + actual = framework_similarity(graph, from_="fixture-wf1", to="fixture-wf2", n=5) + assert_matches_golden(actual, "framework_similarity", sort_by=["from_unit", "rank"]) + + +def test_framework_similarity_no_units_on_either_side_is_empty() -> None: + """A slug with no organizing units returns the zero-row frame with the contract's columns.""" + graph = _load_fixture_graph() + result = framework_similarity(graph, from_="no-such-framework", to="fixture-wf2", n=5) + assert list(result.columns) == ["from_unit", "to_unit", "score", "strength", "rank"] + assert len(result) == 0 + + +def test_framework_similarity_same_slug_on_both_sides_finds_self_matches() -> None: + """from == to is allowed, e.g. to find near-duplicate units within one framework.""" + graph = _load_fixture_graph() + result = framework_similarity(graph, from_="fixture-wf1", to="fixture-wf1", n=5) + # Every unit is its own perfect match (score 1.0, rank 1). + self_matches = result[result["from_unit"] == result["to_unit"]] + assert (self_matches["score"] == 1.0).all() + + +# --- tokenize() edge cases ------------------------------------------------- + + +def test_tokenize_empty_text_returns_empty_list() -> None: + assert _tokenize("") == [] + assert _tokenize(None) == [] + + +def test_tokenize_all_stopwords_returns_empty_list() -> None: + assert _tokenize("the and or is are") == [] + + +def test_tokenize_drops_short_tokens() -> None: + # "AI", "OS", "IT", "5G" are all below the length-3 floor and dropped. + assert _tokenize("AI OS IT 5G") == [] + + +def test_tokenize_lowercases_and_dedupes() -> None: + assert _tokenize("Network network NETWORK traffic") == ["network", "traffic"] + + +def test_tokenize_non_ascii_splits_on_accented_characters() -> None: + # "café" -> "caf" once the trailing accented letter is treated as a + # split boundary (matches R's `[^a-z0-9]+` under the byte-literal + # character class, not a Unicode letter class). + assert _tokenize("café") == ["caf"] + + +def test_tokenize_non_ascii_french_text() -> None: + tokens = _tokenize("Protéger les données personnelles et respecter la vie privée") + assert "prot" in tokens # "protéger" splits on the accented "é" boundary + assert "les" in tokens # 3 chars, not a stopword (the workforce list is English-only) + assert "personnelles" in tokens + + +# --- jaccard() edge cases --------------------------------------------------- + + +def test_jaccard_both_empty_is_zero() -> None: + assert _jaccard([], []) == 0.0 + + +def test_jaccard_one_side_empty_is_zero() -> None: + assert _jaccard(["network"], []) == 0.0 + assert _jaccard([], ["network"]) == 0.0 + + +def test_jaccard_identical_sets_is_one() -> None: + assert _jaccard(["network", "traffic"], ["network", "traffic"]) == 1.0 + + +def test_jaccard_partial_overlap() -> None: + # intersection={network}, union={network,traffic,firewall} -> 1/3 + assert _jaccard(["network", "traffic"], ["network", "firewall"]) == 1 / 3 + + +# --- ranking tie-break ------------------------------------------------- + + +def test_top_n_ties_break_by_ascending_candidate() -> None: + """Two to-units with an identical score rank by ascending IRI, not insertion order.""" + graph = rdflib.Graph() + cybed = "https://w3id.org/cybed/ontology#" + triples = f""" + <{cybed}framework/tie-a> <{cybed}Framework> . + <{cybed}framework/tie-b> <{cybed}Framework> . + <{cybed}role/tie-from> <{cybed}OrganizingUnit> . + <{cybed}role/tie-from> <{cybed}partOf> <{cybed}framework/tie-a> . + <{cybed}role/tie-from> "network security incident" . + <{cybed}role/tie-z> <{cybed}OrganizingUnit> . + <{cybed}role/tie-z> <{cybed}partOf> <{cybed}framework/tie-b> . + <{cybed}role/tie-z> "network security incident" . + <{cybed}role/tie-a> <{cybed}OrganizingUnit> . + <{cybed}role/tie-a> <{cybed}partOf> <{cybed}framework/tie-b> . + <{cybed}role/tie-a> "network security incident" . + """ + graph.parse(data=triples, format="nt") + + result = framework_similarity(graph, from_="tie-a", to="tie-b", n=5) + ranked = result.sort_values("rank") + # Both candidates tie at score 1.0; ascending IRI puts tie-a before tie-z. + assert list(ranked["to_unit"]) == [ + f"{cybed}role/tie-a", + f"{cybed}role/tie-z", + ] diff --git a/python/tools/export_r_data.R b/python/tools/export_r_data.R new file mode 100644 index 0000000..e490021 --- /dev/null +++ b/python/tools/export_r_data.R @@ -0,0 +1,50 @@ +# python/tools/export_r_data.R +# +# Regenerates the CSV data files the Python package ships under +# src/cybedtools/_resources/ from the R package's own framework_summary and +# framework_licenses tibbles (R/data.R). Run this from the repository root +# whenever either tibble changes, and commit the resulting CSVs alongside +# the R change so the two ports never drift apart silently. +# +# Usage (from the repository root, with the R package installed or +# loadable via devtools/pkgload): +# +# Rscript python/tools/export_r_data.R +# +# What it writes: +# python/src/cybedtools/_resources/framework_summary.csv +# python/src/cybedtools/_resources/cybed_license.csv +# +# Both are written with readr::write_csv() defaults (UTF-8, no BOM, LF line +# endings, comma-quoted only where required), which is also what +# scripts/040-build-goldens.R uses for inst/conformance/goldens/ -- so the +# shipped Python data and the R conformance goldens for these two functions +# are byte-identical by construction, and the golden files can be spot +# copies of these outputs (see python/tests/fixtures/conformance/goldens/). + +if (!requireNamespace("pkgload", quietly = TRUE)) { + stop("This script requires the 'pkgload' package (install.packages(\"pkgload\")).") +} + +pkgload::load_all(quiet = TRUE) + +out_dir <- file.path("python", "src", "cybedtools", "_resources") +dir.create(out_dir, recursive = TRUE, showWarnings = FALSE) + +readr::write_csv( + cybedtools::framework_summary, + file.path(out_dir, "framework_summary.csv") +) + +readr::write_csv( + cybed_license(), + file.path(out_dir, "cybed_license.csv") +) + +message("Wrote ", file.path(out_dir, "framework_summary.csv")) +message("Wrote ", file.path(out_dir, "cybed_license.csv")) +message( + "Remember to also refresh python/tests/fixtures/conformance/goldens/ ", + "(copy the same two files there) if this ran outside of ", + "scripts/040-build-goldens.R." +) diff --git a/scripts/000-build.R b/scripts/000-build.R index aeb9976..4bd40fe 100644 --- a/scripts/000-build.R +++ b/scripts/000-build.R @@ -42,11 +42,14 @@ ingestion_scripts <- c( "scripts/010-ingest-ecsf.R", "scripts/010-ingest-cyberorg.R", "scripts/010-ingest-csta.R", + "scripts/010-ingest-csta2026.R", "scripts/010-ingest-csec2017.R", "scripts/010-ingest-digcomp.R", "scripts/010-ingest-cyqual.R", "scripts/010-ingest-ccssf.R", - "scripts/010-ingest-otccf.R" + "scripts/010-ingest-otccf.R", + "scripts/010-ingest-scywf.R", + "scripts/010-ingest-cybok.R" ) # Pipeline stages after ingestion diff --git a/scripts/010-ingest-ccssf.R b/scripts/010-ingest-ccssf.R index 646986c..72e0693 100644 --- a/scripts/010-ingest-ccssf.R +++ b/scripts/010-ingest-ccssf.R @@ -37,9 +37,10 @@ # in both the core role blocks and the Annex E table. Those citations are # captured as their own crosswalk table. # -# Licensing: Copyright Government of Canada. Referenced as the Canadian -# Centre for Cyber Security asked. Supplied directly by the Cyber Centre's -# Cyber Skills Development Team, 2026-09-16. +# Licensing: Copyright Government of Canada. Used with permission of the +# Canadian Centre for Cyber Security (written permission received +# 2026-09-21, for full-text publication with attribution). Supplied +# directly by the Cyber Centre's Cyber Skills Development Team, 2026-09-16. # # Run: Rscript scripts/010-ingest-ccssf.R @@ -76,9 +77,9 @@ ccssf_config <- list( isbn = "978-0-660-46231-8", catalogue_number = "D97-4/00-039-2022E-PDF", license = paste( - "Copyright Government of Canada. Referenced as the Canadian Centre for", - "Cyber Security asked. Supplied directly by the Cyber Skills Development", - "Team, 2026-09-16." + "Copyright Government of Canada. Used with permission of the Canadian", + "Centre for Cyber Security. Supplied directly by the Cyber Skills", + "Development Team, 2026-09-16." ) ) @@ -1237,8 +1238,10 @@ write_provenance_manifest <- function(pdf_path, pdf_copy_path, text_path, licensing = list( source_license = ccssf_config$license, redistribution = paste( - "Permission covers use with attribution. Reference the publication", - "(ITSM.00.039) wherever the material or a derivative of it is used." + "Full-text publication with attribution (owner decision 2026-09-21,", + "written permission received from the Canadian Centre for Cyber", + "Security). Reference the publication (ITSM.00.039) wherever the", + "material or a derivative of it is used." ) ), notes = list( diff --git a/scripts/010-ingest-csta2026.R b/scripts/010-ingest-csta2026.R new file mode 100644 index 0000000..776e6bd --- /dev/null +++ b/scripts/010-ingest-csta2026.R @@ -0,0 +1,301 @@ +# 010-ingest-csta2026.R +# +# Ingest the 2026 CSTA PK-12 Computer Science Standards from the staged JSON +# behind CSTA's official interactive Standards Explorer. +# +# Source: csta-2026-standards-api.json (331 records), staged under +# data/raw/csta-2026/ together with the full standards PDF and a hand-written +# provenance.yml that records each staged file's SHA256. This script never +# fetches anything: it reads the staged JSON, and it stops unless every file +# provenance.yml declares is present with the declared hash. +# License: CC BY-NC-SA 4.0 (attribution, non-commercial, share-alike), read +# from the PDF's own licence page (p. iv). +# +# csta-2026 is a framework of its own, not a revision of csta-2017. The two +# editions share no identifier scheme and CSTA's crosswalk between them is +# many-to-many, so both are kept side by side under separate prefixes. +# +# Structure. Every record is one standard with a structured code, +# LEVEL-CONCEPT-SUBCONCEPT-NN (e.g. MS-ALG-PS-01, S1-CYB-ND-01). +# Foundational tier: levels EK, E1..E5, MS, HS x 5 concepts x 18 subconcepts. +# Specialty tier: levels S1, S2 x 7 specialty areas (six areas plus X+CS, +# which has Specialty I only) x 28 subareas. +# For specialty records the explorer puts the area name in `concept` and the +# subarea in `specialtySubArea`, leaving `subconcept` empty. This script +# normalises both tiers onto one concept / subconcept pair. +# +# Tables written to data/raw/csta-2026/tables/: +# standards.csv one row per standard (331) +# boundaries.csv one row per boundary-statement paragraph +# examples.csv one row per implementation example +# units.csv observed (level, concept) groups that become units +# +# Practices, dispositions, progressions and interdisciplinary connections are +# staged in the JSON but not carried into these tables' graph path yet. The +# practice and disposition codes stay on standards.csv as published lists. +# +# Run: Rscript scripts/010-ingest-csta2026.R + +suppressPackageStartupMessages({ + library(here) + library(jsonlite) + library(dplyr) + library(readr) + library(yaml) + library(purrr) + library(tibble) + library(stringr) + library(digest) +}) + +csta2026_config <- list( + staging_dir = here("data", "raw", "csta-2026"), + json_filename = "csta-2026-standards-api.json", + tables_subdir = "tables", + manifest_filename = "provenance.yml" +) + +# Level codes are the first segment of every CSTA identifier. The explorer's +# `grade` field says the same thing in a second vocabulary; both are read and +# checked against each other. +csta2026_levels <- tibble::tribble( + ~level, ~grade, ~tier, + "EK", "PK/K", "foundational", + "E1", "1", "foundational", + "E2", "2", "foundational", + "E3", "3", "foundational", + "E4", "4", "foundational", + "E5", "5", "foundational", + "MS", "MS", "foundational", + "HS", "HS", "foundational", + "S1", "Specialty-I", "specialty", + "S2", "Specialty-II", "specialty" +) + +# --------------------------------------------------------------------------- +# Provenance anchor +# --------------------------------------------------------------------------- + +#' Stop unless every file the manifest declares matches its recorded hash. +verify_staged_hashes <- function(manifest, staging_dir) { + files <- manifest$source$files + if (is.null(files) || length(files) == 0) { + stop("provenance.yml declares no source files to verify.") + } + for (f in files) { + path <- file.path(staging_dir, f$filename) + if (!file.exists(path)) stop("Declared source file missing: ", path) + actual <- digest(file = path, algo = "sha256") + if (!identical(actual, f$file_sha256)) { + stop("SHA256 mismatch for ", f$filename, ": declared ", f$file_sha256, + ", found ", actual) + } + } + invisible(TRUE) +} + +# --------------------------------------------------------------------------- +# Extraction +# --------------------------------------------------------------------------- + +chr_or_empty <- function(x) if (is.null(x) || length(x) == 0) "" else as.character(x) + +# Practice codes as the standards document prints them (ESR1, IC3, CT6, +# HCD10): the explorer gives the practice number in front of its statement +# and the category name as the label. +csta2026_practice_categories <- c( + "Ethics & Social Responsibility" = "ESR", + "Inclusive Collaboration" = "IC", + "Computational Thinking" = "CT", + "Human-Centered Design" = "HCD" +) + +practice_code <- function(pillar) { + abbrev <- unname(csta2026_practice_categories[pillar$label]) + number <- str_extract(pillar$description, "^[0-9]+") + if (is.na(abbrev) || is.na(number)) { + stop("Unrecognised practice alignment: ", pillar$label, " / ", pillar$description) + } + paste0(abbrev, number) +} + +#' One row per standard, both tiers normalised onto concept / subconcept. +extract_csta2026_standards <- function(records) { + standards <- records |> + map(\(r) tibble( + code = chr_or_empty(r$code), + grade = chr_or_empty(r$grade), + concept_raw = chr_or_empty(r$concept), + subconcept_raw = chr_or_empty(r$subconcept), + specialty_area = chr_or_empty(r$specialtyArea), + specialty_subarea = chr_or_empty(r$specialtySubArea), + title = chr_or_empty(r$title), + ai_standard_raw = chr_or_empty(r$ai_standard), + practices = paste(map_chr(r$pillars %||% list(), practice_code), + collapse = ";"), + dispositions = paste(unlist(r$dispositions %||% list()), collapse = ";") + )) |> + list_rbind() + + standards |> + mutate( + level = str_extract(code, "^[^-]+"), + specialty_area_code = if_else(nzchar(specialty_area), + str_split_i(code, "-", 2), NA_character_), + concept = if_else(nzchar(specialty_area), specialty_area, concept_raw), + subconcept = if_else(nzchar(specialty_area), specialty_subarea, subconcept_raw), + ai_standard = ai_standard_raw == "Yes", + source_section = paste(level, concept, subconcept, sep = ".") + ) |> + left_join(csta2026_levels |> rename(grade_expected = grade), by = "level") |> + select(code, level, grade, grade_expected, tier, concept, subconcept, + specialty_area_code, title, ai_standard, ai_standard_raw, + practices, dispositions, source_section) |> + arrange(code) +} + +#' Boundary statements: one row per published paragraph, in source order. +extract_csta2026_boundaries <- function(records) { + records |> + map(\(r) { + txt <- unlist(r$boundaries %||% list()) + tibble(code = rep(chr_or_empty(r$code), length(txt)), + ordinal = seq_along(txt), + text = as.character(txt)) + }) |> + list_rbind() |> + arrange(code, ordinal) +} + +#' Implementation examples: one row per example, in source order. +extract_csta2026_examples <- function(records) { + records |> + map(\(r) { + exs <- r$examples %||% list() + tibble(code = rep(chr_or_empty(r$code), length(exs)), + ordinal = seq_along(exs), + example_type = map_chr(exs, \(e) chr_or_empty(e$type)), + text = map_chr(exs, \(e) chr_or_empty(e$description))) + }) |> + list_rbind() |> + arrange(code, ordinal) +} + +#' Organizing units from the (level, concept) pairs actually observed, never +#' from a crossing: X+CS has Specialty I only, and a crossing would mint an +#' empty S2 unit for it. Foundational units are level x concept; specialty +#' units are tier level (S1/S2) x specialty area. The unit id reuses CSTA's +#' own identifier segments, so MS-ALG groups MS-ALG-*-* and S1-CYB groups +#' S1-CYB-*-*. +build_csta2026_units <- function(standards) { + standards |> + mutate(concept_code = str_split_i(code, "-", 2)) |> + group_by(level, tier, concept, concept_code, specialty_area_code) |> + summarise(standards_count = n(), .groups = "drop") |> + mutate(unit_id = paste(level, concept_code, sep = "-")) |> + select(unit_id, level, tier, concept, concept_code, specialty_area_code, + standards_count) |> + arrange(match(level, csta2026_levels$level), concept_code) +} + +# --------------------------------------------------------------------------- +# Checks +# --------------------------------------------------------------------------- + +check_csta2026_extraction <- function(standards, boundaries, examples, units, manifest) { + ext <- manifest$extraction + problems <- c( + if (nrow(standards) != ext$standards_count) + paste("standards:", nrow(standards), "vs declared", ext$standards_count), + if (anyDuplicated(standards$code)) "duplicate standard codes", + if (any(is.na(standards$tier))) "unknown level prefix in a standard code", + if (any(standards$grade != standards$grade_expected, na.rm = TRUE)) + "code level prefix disagrees with the explorer grade field", + if (sum(standards$tier == "foundational") != ext$foundational_count) + "foundational count differs from provenance", + if (sum(standards$tier == "specialty") != ext$specialty_count) + "specialty count differs from provenance", + if (any(!nzchar(standards$title))) "empty standard text", + if (any(!nzchar(standards$subconcept))) "empty subconcept", + if (!all(standards$ai_standard_raw %in% c("Yes", "No"))) "unexpected ai_standard value", + if (any(!standards$code %in% boundaries$code)) "a standard without a boundary statement", + if (any(!nzchar(str_trim(boundaries$text)))) "empty boundary statement", + if (any(!nzchar(str_trim(examples$text)))) "empty implementation example", + if (length(unique(standards$level)) != ext$levels_count) "level count differs from provenance", + if (n_distinct(standards$specialty_area_code, na.rm = TRUE) != ext$specialty_areas) + "specialty area count differs from provenance" + ) + if (length(problems) > 0) { + stop("csta-2026 extraction failed its checks:\n ", + paste(problems, collapse = "\n ")) + } + invisible(TRUE) +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +main <- function() { + message("=== CSTA PK-12 CS Standards (2026) Ingestion ===") + + manifest_path <- file.path(csta2026_config$staging_dir, csta2026_config$manifest_filename) + if (!file.exists(manifest_path)) stop("provenance.yml not found at ", manifest_path) + manifest <- read_yaml(manifest_path) + + message("Verifying staged files against provenance.yml hashes...") + verify_staged_hashes(manifest, csta2026_config$staging_dir) + + json_path <- file.path(csta2026_config$staging_dir, csta2026_config$json_filename) + records <- fromJSON(json_path, simplifyVector = FALSE) + message(" Records: ", length(records)) + + standards <- extract_csta2026_standards(records) + boundaries <- extract_csta2026_boundaries(records) + examples <- extract_csta2026_examples(records) + units <- build_csta2026_units(standards) + + check_csta2026_extraction(standards, boundaries, examples, units, manifest) + + tables_dir <- file.path(csta2026_config$staging_dir, csta2026_config$tables_subdir) + dir.create(tables_dir, showWarnings = FALSE, recursive = TRUE) + + write_csv(standards |> select(-grade_expected, -ai_standard_raw), + file.path(tables_dir, "standards.csv"), na = "") + write_csv(boundaries, file.path(tables_dir, "boundaries.csv")) + write_csv(examples, file.path(tables_dir, "examples.csv")) + write_csv(units, file.path(tables_dir, "units.csv"), na = "") + + # The hand-written manifest is the provenance anchor and is kept as staged. + # Only the counts this script measures are added under `extraction`, so the + # ingestion summary can report them. + manifest$extraction$organizing_units <- nrow(units) + manifest$extraction$foundational_units <- sum(units$tier == "foundational") + manifest$extraction$specialty_units <- sum(units$tier == "specialty") + manifest$extraction$boundary_statements <- nrow(boundaries) + manifest$extraction$implementation_examples <- nrow(examples) + manifest$extraction$standards_with_examples <- n_distinct(examples$code) + manifest$extraction$ai_standards <- sum(standards$ai_standard) + write_yaml(manifest, manifest_path) + + message("\n=== Summary ===") + message(" Standards: ", nrow(standards), + " (", sum(standards$tier == "foundational"), " foundational, ", + sum(standards$tier == "specialty"), " specialty)") + message(" Organizing units: ", nrow(units), + " (", sum(units$tier == "foundational"), " foundational, ", + sum(units$tier == "specialty"), " specialty)") + message(" Boundary statement paragraphs: ", nrow(boundaries)) + message(" Implementation examples: ", nrow(examples), + " across ", n_distinct(examples$code), " standards") + message(" Cybersecurity (CYB) standards: ", + sum(standards$specialty_area_code %in% "CYB")) + message("\nDone.") + + invisible(list(standards = standards, boundaries = boundaries, + examples = examples, units = units)) +} + +if (sys.nframe() == 0) { + main() +} diff --git a/scripts/010-ingest-cybok.R b/scripts/010-ingest-cybok.R new file mode 100644 index 0000000..44c31d9 --- /dev/null +++ b/scripts/010-ingest-cybok.R @@ -0,0 +1,833 @@ +# 010-ingest-cybok.R +# +# Ingest the Cyber Security Body of Knowledge (CyBOK) v1.1.0, July 2021, +# published by the National Cyber Security Centre (NCSC) under the Open +# Government Licence v3.0. +# +# Source: PDFs only. CyBOK publishes no CSV, JSON or XML. Every file is staged +# under data/raw/cybok/ with a provenance.yml that records its URL, version +# and SHA256. This script never fetches anything, and it stops unless every +# declared file is present with its declared hash. +# +# Introduction_v1.1.0.pdf the Introduction to CyBOK Knowledge Area. Its +# Figure 2 ("Short descriptions of CyBOK Knowledge Areas") is a text +# table that prints the 5 categories as header rows and the 21 +# Knowledge Area names beneath them. Source of the KA names and of each +# KA's category. +# trees/*.pdf one Knowledge Tree per KA, each versioned with +# its KA. CyBOK defines its own levels on these trees: "The nodes +# directly under the root node are referred to as Topics" and +# "Indicative Material is defined as the nodes in the Knowledge Tree one +# layer further down from the Topic" (A-to-Z document, Introduction). +# Source of the Topic titles, the Indicative Material terms and which +# Topic each term hangs from. +# A_to_Z_CyBOK_KA_1_1_July_2021.pdf the alphabetical Indicative Material +# index: a three-column table (term, Topic, KA acronym). Source of the +# KA acronyms, which key the KA IRIs. Its rows are otherwise used only +# to cross-check the trees. Nothing else is taken from it. +# text/*.raw.txt the same PDFs read by a second program (xpdf +# pdftotext 4.06 -raw -enc UTF-8), for the verbatim check only. +# +# Text normalisation, the complete list: +# 1. Whitespace. Every run of whitespace, including a line break, becomes +# one space. Leading and trailing whitespace goes. +# 2. Line-break hyphenation. A line break directly after a hyphen that +# follows a letter is removed without inserting a space, and the hyphen +# is kept. +# No other character is changed. Case in particular is kept as printed: the +# Knowledge Trees print most Topics in lower case and the A-to-Z prints +# everything in capitals, and both are carried as printed. +# +# Reading a Knowledge Tree. A tree is a drawing: each node is a boxed label +# and each parent-child link is a stroked line, which the text layer does not +# carry. Node labels come from the text layer (pdftools word coordinates). +# The links come from the drawing itself: the page is rendered, every node +# box is blanked out, and the remaining ink is split into connected +# components. One component is the fan of lines leaving one node, so a +# component touching two node boxes links them. The component that reaches +# the far left of the drawing is the root's fan, and the nodes it touches are +# the Topics. A component that touches a Topic and a node to its right links +# that node to the Topic as its Indicative Material. This is how CyBOK +# defines both levels, and it does not rely on node position: at least one +# tree (Distributed Systems Security) draws a Topic in the column where the +# other Topics' children sit. +# +# Cross-check against the A-to-Z. Every A-to-Z row (term, Topic, KA) is +# resolved against the trees and the result written row by row. Matching +# folds case, because the A-to-Z sets every cell in capitals, and accepts a +# line-break hyphen with or without the hyphen, because the A-to-Z's +# justified columns break words mid-syllable. Both are matching rules only +# and never change a carried string. Rows that do not resolve are reported, +# not corrected. +# +# The Introduction Knowledge Tree (acronym CI in the A-to-Z) is staged and +# parsed for the cross-check but is not a Knowledge Area and is not ingested. +# +# Tables written to data/raw/cybok/tables/: +# categories.csv the 5 categories of Figure 2, in printed order +# knowledge-areas.csv the 21 KAs: acronym, name, category, version, +# tree title, tree file +# topics.csv one row per Topic, per KA in tree order +# indicative-material.csv one row per Indicative Material node, under its +# Topic in tree order +# a-to-z.csv the A-to-Z table, flat, one row per printed row +# a-to-z-resolution.csv each A-to-Z row resolved against the trees +# verbatim-check.csv every carried string looked up in the +# independent extraction +# crosswalk-ka-resolution.csv the staged SFIA and ECSF crosswalks' KA +# names resolved to KA acronyms +# +# Run: Rscript scripts/010-ingest-cybok.R + +suppressPackageStartupMessages({ + library(here) + library(pdftools) + library(dplyr) + library(readr) + library(yaml) + library(purrr) + library(tibble) + library(stringr) + library(digest) +}) + +cybok_config <- list( + staging_dir = here("data", "raw", "cybok"), + tables_subdir = "tables", + manifest_filename = "provenance.yml", + # Page furniture on every CyBOK page: the running header above y = 45 and + # the footer below y = 790. + header_max_y = 45, + footer_min_y = 790, + # Knowledge Tree text: words on one baseline further apart than this + # belong to different nodes. + node_gap = 6, + # Knowledge Tree drawing: render resolution, the grey level below which a + # pixel counts as ink (the lines are thin and anti-aliased), and how far + # outside a node box a line still counts as touching it, in pixels. + render_dpi = 300, + ink_below = 215, + touch_px = 3 +) + +`%||%` <- function(a, b) if (is.null(a)) b else a + +# --------------------------------------------------------------------------- +# Provenance anchor +# --------------------------------------------------------------------------- + +#' Stop unless every file the manifest declares matches its recorded hash. +verify_cybok_hashes <- function(manifest, staging_dir) { + files <- manifest$source$files + if (is.null(files) || length(files) == 0) { + stop("provenance.yml declares no source files to verify.") + } + for (f in files) { + path <- file.path(staging_dir, f$filename) + if (!file.exists(path)) stop("Declared source file missing: ", path) + actual <- digest(file = path, algo = "sha256") + if (!identical(actual, f$file_sha256)) { + stop("SHA256 mismatch for ", f$filename, ": declared ", f$file_sha256, + ", found ", actual) + } + } + invisible(TRUE) +} + +#' The declared files of one role, as a tibble. +cybok_files_of_role <- function(manifest, file_role) { + manifest$source$files |> + keep(\(f) identical(f$role, file_role)) |> + map(\(f) tibble(filename = f$filename, + ka_acronym = f$ka_acronym %||% NA_character_, + ka_name = f$ka_name %||% NA_character_, + version = f$version %||% NA_character_, + text_of = f$text_of %||% NA_character_)) |> + bind_rows() +} + +# --------------------------------------------------------------------------- +# Text normalisation (the two documented rules, nothing else) +# --------------------------------------------------------------------------- + +#' Join printed lines into one string under the two rules. With +#' `soft_hyphen = TRUE` a line-break hyphen is dropped instead of kept, which +#' is used only to match the A-to-Z and never for a carried string. +cybok_join_lines <- function(lines, soft_hyphen = FALSE) { + lines <- str_squish(lines) + lines <- lines[nzchar(lines)] + if (length(lines) == 0) return("") + out <- lines[1] + for (ln in lines[-1]) { + if (str_detect(out, "[[:alpha:]]-$")) { + out <- paste0(if (soft_hyphen) str_remove(out, "-$") else out, ln) + } else { + out <- paste(out, ln) + } + } + out +} + +#' Apply the same two rules to free text, for the verbatim check. +cybok_normalise_text <- function(x) { + x |> + str_replace_all("(?<=[[:alpha:]]-)[ \\t]*\\r?\\n\\s*", "") |> + str_squish() +} + +#' Case fold for matching the capitalised A-to-Z against the trees. Matching +#' only: never applied to a carried string. +cybok_fold <- function(x) str_to_lower(str_squish(x)) + +# --------------------------------------------------------------------------- +# Text-layer geometry +# --------------------------------------------------------------------------- + +#' Assign words to printed lines by baseline. +cybok_line_ids <- function(y, tol = 2) { + if (length(y) == 0) return(integer(0)) + ord <- order(y) + id <- integer(length(y)) + current <- 1L + anchor <- y[ord[1]] + for (i in ord) { + if (y[i] - anchor > tol) { + current <- current + 1L + anchor <- y[i] + } + id[i] <- current + } + id +} + +#' Words grouped into printed lines, x ordered, as text. +cybok_lines <- function(words) { + if (nrow(words) == 0) return(tibble(y = numeric(), x0 = numeric(), text = character())) + words |> + mutate(line = cybok_line_ids(y)) |> + arrange(line, x) |> + group_by(line) |> + summarise(y = min(y), x0 = min(x), text = paste(text, collapse = " "), + .groups = "drop") |> + select(-line) +} + +# --------------------------------------------------------------------------- +# Introduction, Figure 2: categories and Knowledge Areas +# --------------------------------------------------------------------------- + +#' Parse Figure 2 of the Introduction. +#' +#' Category headers are the small-type lines of the description column. KA +#' names are the left column. A name printed on several lines is one cell: its +#' lines sit one line pitch apart, while two cells are separated by the row +#' rule and padding. +#' +#' @param words pdf_data() words of the Figure 2 page. +#' @return List: categories (category_order, category_name) and kas +#' (ka_order, ka_name, category_name). +cybok_parse_figure2 <- function(words, name_x_max = 160, pitch = 11) { + caption <- words |> filter(text == "Figure", lead(text) == "2:") + if (nrow(caption) != 1) stop("Figure 2 caption not found on the page.") + words <- words |> + filter(y > cybok_config$header_max_y, y < caption$y - 2) + headers <- cybok_lines(words |> filter(height < 9, x >= name_x_max)) |> arrange(y) + names_l <- cybok_lines(words |> filter(height >= 9, x < name_x_max)) |> arrange(y) + kas <- names_l |> + mutate(cell = cumsum(c(TRUE, diff(y) > pitch))) |> + group_by(cell) |> + summarise(y = min(y), ka_name = cybok_join_lines(text), .groups = "drop") + kas$category_name <- headers$text[findInterval(kas$y, headers$y)] + list( + categories = tibble(category_order = seq_len(nrow(headers)), + category_name = headers$text), + kas = tibble(ka_order = seq_len(nrow(kas)), ka_name = kas$ka_name, + category_name = kas$category_name) + ) +} + +# --------------------------------------------------------------------------- +# A-to-Z: acronym table and the Indicative Material index +# --------------------------------------------------------------------------- + +#' The acronym table ("Acronym Knowledge Area"). +cybok_parse_acronyms <- function(words) { + head_row <- words |> filter(text == "Acronym") + note <- words |> filter(str_detect(text, "^Note")) + if (nrow(head_row) != 1 || nrow(note) < 1) stop("A-to-Z acronym table not found.") + body <- words |> filter(y > head_row$y + 3, y < min(note$y) - 3) + name_x <- min(body$x[body$x > head_row$x + head_row$width]) + body |> + mutate(line = cybok_line_ids(y)) |> + arrange(line, x) |> + group_by(line) |> + summarise(acronym = paste(text[x < name_x], collapse = " "), + az_ka_name = paste(text[x >= name_x], collapse = " "), + .groups = "drop") |> + select(acronym, az_ka_name) +} + +#' Parse one A-to-Z table page into rows. +#' +#' Every cell is top aligned, so a row starts on the baseline of its KA +#' acronym (set in smaller type a point lower) and runs to the next one. The +#' red section letters (A, B, ...) are larger type and are dropped. +cybok_parse_atoz_page <- function(words, term_x_max = 240, ka_x_min = 470) { + head_row <- words |> filter(text == "INDICATIVE", x < 100) + if (nrow(head_row) == 0) return(NULL) + body <- words |> + filter(y > head_row$y[1] + 5, y < cybok_config$footer_min_y) |> + filter(!(height >= 11 & x < term_x_max)) + anchors <- body |> filter(x >= ka_x_min) |> arrange(y) + if (nrow(anchors) == 0) return(NULL) + term_l <- cybok_lines(body |> filter(x < term_x_max)) + topic_l <- cybok_lines(body |> filter(x >= term_x_max, x < ka_x_min)) + starts <- c(anchors$y - 3, Inf) + if (any(term_l$y < starts[1]) || any(topic_l$y < starts[1])) { + stop("A-to-Z page: text above the first row anchor.") + } + map(seq_len(nrow(anchors)), \(k) { + tl <- term_l |> filter(y >= starts[k], y < starts[k + 1]) + pl <- topic_l |> filter(y >= starts[k], y < starts[k + 1]) + tibble(indicative_material = cybok_join_lines(tl$text), + topic = cybok_join_lines(pl$text), + ka_acronym = anchors$text[k], + indicative_material_soft = cybok_join_lines(tl$text, soft_hyphen = TRUE), + topic_soft = cybok_join_lines(pl$text, soft_hyphen = TRUE)) + }) |> bind_rows() +} + +cybok_parse_atoz <- function(pages) { + map(seq_along(pages), \(p) { + rows <- cybok_parse_atoz_page(pages[[p]]) + if (is.null(rows)) return(NULL) + rows |> mutate(page = p, .before = 1) + }) |> + bind_rows() |> + mutate(row = row_number(), .before = 1) +} + +# --------------------------------------------------------------------------- +# Knowledge Trees: node labels from the text layer +# --------------------------------------------------------------------------- + +#' Rebuild the node labels of one Knowledge Tree page from its words. Every +#' label sits on one baseline, and words on a baseline split into labels where +#' the horizontal gap exceeds `node_gap`. +cybok_tree_labels <- function(words, node_gap = cybok_config$node_gap) { + words |> + mutate(line = cybok_line_ids(y)) |> + arrange(line, x) |> + group_by(line) |> + mutate(node = cumsum(c(TRUE, diff(x) - head(width, -1) > node_gap))) |> + group_by(line, node) |> + summarise(y = min(y), x0 = min(x), x1 = max(x + width), + text = paste(text, collapse = " "), .groups = "drop") |> + arrange(y, x0) |> + transmute(node_id = row_number(), text, x0, x1, y) +} + +#' The title printed on a tree's cover ("Network Security Knowledge Tree"). +cybok_tree_title <- function(cover_words) { + lines <- cybok_lines(cover_words |> filter(height > 15))$text + str_remove(cybok_join_lines(lines), "^The Cyber Security Body of Knowledge ") +} + +# --------------------------------------------------------------------------- +# Knowledge Trees: links from the drawing +# --------------------------------------------------------------------------- + +#' Find the box drawn around one label, in pixels of a rendered page. +#' +#' Searches outward from the label's text box for the first row above and +#' below that is ink across the label's width, then for the first column +#' left and right that is ink down the box's height, then walks each side to +#' the outer edge of its stroke. +#' +#' @param ink Logical matrix, ink[x, y]. +#' @param x0,x1,y0,y1 The label's text box, in points. +#' @param s Pixels per point. +#' @param search How far outside the text box to look, in points. +#' @return Named integer vector (l, r, t, b), or NULL when no box is found. +cybok_find_box <- function(ink, x0, x1, y0, y1, s, search) { + span <- round(x0 * s):round(x1 * s) + span <- span[span >= 1 & span <= nrow(ink)] + full_row <- function(r) r >= 1 && r <= ncol(ink) && mean(ink[span, r]) > 0.9 + up <- rev(round((y0 - search) * s):round(y0 * s)) + down <- round(y1 * s):round((y1 + search) * s) + top <- up[which(vapply(up, full_row, logical(1)))[1]] + bot <- down[which(vapply(down, full_row, logical(1)))[1]] + if (is.na(top) || is.na(bot) || bot - top < 4) return(NULL) + inner <- (top + 2):(bot - 2) + full_col <- function(cc) cc >= 1 && cc <= nrow(ink) && mean(ink[cc, inner]) > 0.9 + left_c <- rev(round((x0 - search) * s):(round(x0 * s) + round(s))) + right_c <- (round(x1 * s) - round(s)):round((x1 + search) * s) + left <- left_c[which(vapply(left_c, full_col, logical(1)))[1]] + right <- right_c[which(vapply(right_c, full_col, logical(1)))[1]] + if (is.na(left) || is.na(right)) return(NULL) + while (full_row(top - 1)) top <- top - 1 + while (full_row(bot + 1)) bot <- bot + 1 + while (full_col(left - 1)) left <- left - 1 + while (full_col(right + 1)) right <- right + 1 + c(l = left, r = right, t = top, b = bot) +} + +#' Split ink into 8-connected components. +#' +#' @param ink Logical matrix, ink[x, y]. +#' @return Data frame of horizontal ink runs (y, x_from, x_to, comp). +cybok_ink_components <- function(ink) { + runs <- map(seq_len(ncol(ink)), \(j) { + v <- ink[, j] + if (!any(v)) return(NULL) + r <- rle(v) + ends <- cumsum(r$lengths) + starts <- ends - r$lengths + 1 + data.frame(y = j, x_from = starts[r$values], x_to = ends[r$values]) + }) |> bind_rows() + if (nrow(runs) == 0) return(data.frame(y = integer(), x_from = integer(), + x_to = integer(), comp = integer())) + parent <- seq_len(nrow(runs)) + find <- function(i) { + while (parent[i] != i) { + parent[i] <<- parent[parent[i]] + i <- parent[i] + } + i + } + by_row <- split(seq_len(nrow(runs)), runs$y) + rows <- as.integer(names(by_row)) + for (k in seq_along(rows)[-1]) { + if (rows[k] - rows[k - 1] != 1) next + above <- by_row[[k - 1]] + for (i in by_row[[k]]) { + hit <- above[runs$x_from[above] <= runs$x_to[i] + 1 & + runs$x_from[i] <= runs$x_to[above] + 1] + for (a in hit) { + ri <- find(i) + ra <- find(a) + if (ri != ra) parent[ri] <- ra + } + } + } + runs$comp <- vapply(seq_len(nrow(runs)), find, integer(1)) + runs +} + +#' Read the Topics and their Indicative Material off a tree drawing. +#' +#' @param labels Tibble from cybok_tree_labels(): node_id, text, x0, x1, y. +#' @param ink Logical matrix ink[x, y] of the rendered page. +#' @param s Pixels per point. +#' @param label_height Text height of the labels, in points. +#' @return List: nodes (labels plus is_topic), links (topic node_id, child +#' node_id), and problems (character). +cybok_tree_links <- function(labels, ink, s, label_height, + touch = cybok_config$touch_px) { + search <- max(6, 2.5 * label_height) + boxes <- map(seq_len(nrow(labels)), \(i) + cybok_find_box(ink, labels$x0[i], labels$x1[i], labels$y[i], + labels$y[i] + label_height, s, search)) + problems <- character(0) + unboxed <- which(map_lgl(boxes, is.null)) + if (length(unboxed) > 0) { + problems <- c(problems, paste0("no box found around \"", labels$text[unboxed], "\"")) + return(list(nodes = mutate(labels, is_topic = FALSE), + links = tibble(topic = integer(), child = integer()), + problems = problems)) + } + for (b in boxes) ink[(b["l"] - 1):(b["r"] + 1), (b["t"] - 1):(b["b"] + 1)] <- FALSE + runs <- cybok_ink_components(ink) + touching <- map(boxes, \(b) unique(runs$comp[ + runs$y >= b["t"] - 1 - touch & runs$y <= b["b"] + 1 + touch & + runs$x_to >= b["l"] - 1 - touch & runs$x_from <= b["r"] + 1 + touch])) + + # The root is the unlabelled point at the far left where the Topic lines + # meet. Its fan is every component that reaches that point (the lines meet + # at a point, so antialiasing can leave more than one component there), and + # the boxes the fan touches are the Topics. + linking <- unique(unlist(touching)) + if (length(linking) == 0) { + return(list(nodes = mutate(labels, is_topic = FALSE), + links = tibble(topic = integer(), child = integer()), + problems = "no lines touch any node box")) + } + linked_runs <- runs[runs$comp %in% linking, ] + root_x <- min(linked_runs$x_from) + root_y <- linked_runs$y[linked_runs$x_from == root_x] + near_root <- abs(linked_runs$x_from - root_x) <= round(2 * s) & + linked_runs$y >= min(root_y) - round(2 * s) & linked_runs$y <= max(root_y) + round(2 * s) + root <- unique(linked_runs$comp[near_root]) + # A root line that grazes another Topic's box is cut in two by the blanked + # box. The far piece never reaches past the right side of the Topic box it + # grazed, which every line leaving a Topic for its children does, so such + # pieces join the root's fan until nothing changes. + comp_max_x <- tapply(runs$x_to, runs$comp, max) + repeat { + is_topic <- map_lgl(touching, \(t) any(root %in% t)) + grazing <- unique(unlist(map(which(is_topic), \(t) { + cs <- setdiff(touching[[t]], root) + cs[comp_max_x[as.character(cs)] <= boxes[[t]]["r"] + 1] + }))) + if (length(grazing) == 0) break + root <- c(root, grazing) + } + centre <- (labels$x0 + labels$x1) / 2 + + links <- map(which(is_topic), \(t) { + fan <- setdiff(touching[[t]], root) + kids <- which(!is_topic & centre > centre[t] & + map_lgl(touching, \(c) any(c %in% fan))) + tibble(topic = rep(labels$node_id[t], length(kids)), child = labels$node_id[kids]) + }) |> bind_rows() + multi <- links |> count(child) |> filter(n > 1) + if (nrow(multi) > 0) { + problems <- c(problems, paste0("\"", labels$text[match(multi$child, labels$node_id)], + "\" hangs from ", multi$n, " Topics")) + } + list(nodes = mutate(labels, is_topic = is_topic), links = links, problems = problems) +} + +#' Parse one tree PDF: its cover title, its Topics and their Indicative +#' Material. +cybok_parse_tree <- function(pdf_path, dpi = cybok_config$render_dpi) { + pages <- pdf_data(pdf_path) + p <- length(pages) + words <- pages[[p]] |> + filter(y > cybok_config$header_max_y, y < cybok_config$footer_min_y) + labels <- cybok_tree_labels(words) + bitmap <- pdf_render_page(pdf_path, page = p, dpi = dpi, numeric = FALSE) + grey <- pmax(as.integer(bitmap[1, , ]), as.integer(bitmap[2, , ]), as.integer(bitmap[3, , ])) + ink <- matrix(grey < cybok_config$ink_below, nrow = dim(bitmap)[2], ncol = dim(bitmap)[3]) + s <- dpi / 72 + ink[, c(seq_len(round(cybok_config$header_max_y * s)), + round(cybok_config$footer_min_y * s):ncol(ink))] <- FALSE + graph <- cybok_tree_links(labels, ink, s, label_height = median(words$height)) + list(title = cybok_tree_title(pages[[1]]), nodes = graph$nodes, + links = graph$links, problems = graph$problems) +} + +#' Topics and Indicative Material of one parsed tree, in tree order (top to +#' bottom). +cybok_tree_tables <- function(tree, acronym) { + topics <- tree$nodes |> + filter(is_topic) |> + arrange(y, x0) |> + mutate(ka_acronym = acronym, ordinal = row_number(), + topic_id = sprintf("%s-%02d", acronym, ordinal)) + im <- tree$links |> + left_join(topics |> select(topic = node_id, topic_id, topic_ordinal = ordinal), + by = "topic") |> + left_join(tree$nodes |> select(child = node_id, term = text, y, x0), by = "child") |> + arrange(topic_ordinal, y, x0) |> + group_by(topic_id) |> + mutate(ordinal = row_number()) |> + ungroup() |> + transmute(ka_acronym = acronym, topic_id, ordinal, term) + list(topics = topics |> select(ka_acronym, topic_id, ordinal, title = text), + im = im) +} + +# --------------------------------------------------------------------------- +# Verification +# --------------------------------------------------------------------------- + +#' Documented, closed exceptions to the verbatim check. +#' +#' Two Applied Cryptography Indicative Material terms are carried as the +#' printed form the tree diagram shows -- "Message Authentication Code (MAC) +#' schemes" and "Authenticated Encryption (AE) schemes" -- even though the +#' tree's own PDF text layer AND the independent xpdf verification extraction +#' both drop the same spaces. That agreement is itself the evidence: it is a +#' PDF encoding fault in the source document, not an extraction bug, so a +#' looser matching rule would not be the correct fix. This list is the only +#' place that difference is allowed; nothing else may take this path. Owner +#' decision 2026-09-21. +#' +#' @param path Staged exceptions CSV: field, id, encoded_text (what both +#' extractions actually produce), corrected_text (what is carried), +#' rationale. +cybok_read_verbatim_exceptions <- function(path) { + if (!file.exists(path)) { + return(tibble(field = character(), id = character(), + encoded_text = character(), corrected_text = character(), + rationale = character())) + } + read_csv(path, show_col_types = FALSE) +} + +#' Apply the documented exceptions to the carried Indicative Material terms +#' (im$term), by topic_id + ordinal ("AC-02.4"). Called after the A-to-Z +#' cross-check is resolved, so resolution matching still sees the strings as +#' both extractions actually produced them. +cybok_apply_verbatim_exceptions <- function(im, exceptions) { + ex <- exceptions[exceptions$field == "indicative_material", ] + if (nrow(ex) == 0) return(im) + key <- paste0(im$topic_id, ".", im$ordinal) + i <- match(key, ex$id) + im$term <- ifelse(!is.na(i), ex$corrected_text[i], im$term) + im +} + +#' Look every carried string up in the independent extraction of its own +#' source document, after the two documented normalisations. A row listed in +#' `exceptions` is instead looked up by its documented encoded form, and +#' flagged in the `verbatim_exception` column so the exception is visible in +#' the written table, not just satisfied silently. +cybok_verbatim_check <- function(checks, reference_texts, exceptions = NULL) { + ref <- map_chr(reference_texts, cybok_normalise_text) + if (is.null(exceptions) || nrow(exceptions) == 0) { + return(checks |> + mutate(verbatim_exception = FALSE, + found = map2_lgl(text, source_file, \(t, s) grepl(t, ref[[s]], fixed = TRUE)))) + } + ex_key <- paste(exceptions$field, exceptions$id) + i <- match(paste(checks$field, checks$id), ex_key) + lookup_text <- ifelse(!is.na(i), exceptions$encoded_text[i], checks$text) + checks |> + mutate(verbatim_exception = !is.na(i), + found = map2_lgl(lookup_text, source_file, \(t, s) grepl(t, ref[[s]], fixed = TRUE))) +} + +#' Resolve A-to-Z rows against the trees' Topics and Indicative Material. +#' +#' @param atoz Parsed A-to-Z rows. +#' @param topics,im Tables from the trees, including the Introduction tree +#' under acronym "CI". +cybok_resolve_atoz <- function(atoz, topics, im) { + topic_keys <- topics |> transmute(ka_acronym, key = cybok_fold(title), topic_id, tree_topic = title) + term_keys <- im |> + transmute(topic_id, key = cybok_fold(term), term_in_tree = term) |> + distinct(topic_id, key, .keep_all = TRUE) + match_topic <- function(acr, hard, soft) { + k <- topic_keys[topic_keys$ka_acronym == acr, ] + i <- match(cybok_fold(hard), k$key) + if (is.na(i)) i <- match(cybok_fold(soft), k$key) + if (is.na(i)) c(NA_character_, NA_character_) else c(k$topic_id[i], k$tree_topic[i]) + } + hit <- pmap(list(atoz$ka_acronym, atoz$topic, atoz$topic_soft), match_topic) + atoz$topic_id <- map_chr(hit, 1) + atoz$tree_topic <- map_chr(hit, 2) + atoz$term_in_tree <- pmap_chr(list(atoz$topic_id, atoz$indicative_material, + atoz$indicative_material_soft), \(tid, hard, soft) { + if (is.na(tid)) return(NA_character_) + k <- term_keys[term_keys$topic_id == tid, ] + i <- match(cybok_fold(hard), k$key) + if (is.na(i)) i <- match(cybok_fold(soft), k$key) + if (is.na(i)) NA_character_ else k$term_in_tree[i] + }) + # Where the row does not resolve, say whether the term is in the KA's tree + # at all, and under which Topic, so a Topic worded differently in the two + # documents can be told apart from a term the tree does not have. + ka_terms <- im |> + transmute(ka_acronym = str_remove(topic_id, "-[0-9]+$"), topic_id, + key = cybok_fold(term)) + atoz$term_topic_in_tree <- pmap_chr(list(atoz$ka_acronym, atoz$indicative_material, + atoz$indicative_material_soft), \(acr, hard, soft) { + k <- ka_terms[ka_terms$ka_acronym == acr, ] + hit <- k$topic_id[k$key %in% cybok_fold(c(hard, soft))] + if (length(hit) == 0) NA_character_ else paste(unique(hit), collapse = " ") + }) + atoz |> + mutate(status = case_when( + !is.na(topic_id) & !is.na(term_in_tree) ~ "resolved", + is.na(topic_id) & !is.na(term_topic_in_tree) ~ "topic worded differently, term in tree", + is.na(topic_id) ~ "topic and term not in tree", + !is.na(term_topic_in_tree) ~ "term under a different topic in the tree", + TRUE ~ "topic resolved, term not in tree" + )) +} + +# --------------------------------------------------------------------------- +# Crosswalk resolution (the staged SFIA and ECSF crosswalks) +# --------------------------------------------------------------------------- + +#' Resolve the KA names the staged crosswalks print to KA acronyms. Exact +#' match first. The ECSF study writes "and" where Figure 2 prints "&", so a +#' second pass accepts that one substitution and says so. +cybok_resolve_crosswalk_kas <- function(ka_names, kas) { + tibble(printed = unique(ka_names)) |> + mutate( + exact = kas$ka_acronym[match(printed, kas$ka_name)], + amp = kas$ka_acronym[match(str_replace_all(printed, " and ", " & "), kas$ka_name)], + ka_acronym = coalesce(exact, amp), + resolution = case_when(!is.na(exact) ~ "exact", + !is.na(amp) ~ "and read as ampersand", + TRUE ~ "unresolved") + ) |> + select(printed, ka_acronym, resolution) +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +main <- function() { + message("=== CyBOK v1.1.0 Ingestion ===") + staging <- cybok_config$staging_dir + manifest_path <- file.path(staging, cybok_config$manifest_filename) + if (!file.exists(manifest_path)) stop("provenance.yml not found at ", manifest_path) + manifest <- read_yaml(manifest_path) + + message("Verifying staged files against provenance.yml hashes...") + verify_cybok_hashes(manifest, staging) + + intro_file <- cybok_files_of_role(manifest, "introduction")$filename + atoz_file <- cybok_files_of_role(manifest, "indicative_material_index")$filename + trees <- cybok_files_of_role(manifest, "knowledge_tree") + intro_tree <- cybok_files_of_role(manifest, "introduction_tree")$filename + check_of <- cybok_files_of_role(manifest, "verification_text") + + # Figure 2. + intro_pages <- pdf_data(file.path(staging, intro_file)) + fig2_page <- which(map_lgl(intro_pages, \(w) any(w$text == "Short") && + any(w$text == "descriptions"))) + if (length(fig2_page) != 1) stop("Figure 2 page not found in the Introduction.") + fig2 <- cybok_parse_figure2(intro_pages[[fig2_page]]) + categories <- fig2$categories + + # A-to-Z. + atoz_pages <- pdf_data(file.path(staging, atoz_file)) + acr_page <- which(map_lgl(atoz_pages, \(w) any(w$text == "Acronym"))) + acronyms <- cybok_parse_acronyms(atoz_pages[[acr_page[1]]]) + atoz <- cybok_parse_atoz(atoz_pages) + + # Knowledge Areas: each declared tree file binds an acronym to a Figure 2 + # name. Both halves are checked against the documents below. + kas <- trees |> + select(ka_acronym, ka_name, version, tree_file = filename) |> + left_join(fig2$kas, by = "ka_name") |> + left_join(acronyms, by = c("ka_acronym" = "acronym")) |> + arrange(ka_order) + + message("Reading ", nrow(kas) + 1, " Knowledge Trees...") + parsed <- map(file.path(staging, kas$tree_file), cybok_parse_tree) + kas$tree_title <- map_chr(parsed, "title") + tables <- map2(parsed, kas$ka_acronym, cybok_tree_tables) + topics <- map(tables, "topics") |> bind_rows() + im <- map(tables, "im") |> bind_rows() + tree_problems <- unlist(map2(parsed, kas$ka_acronym, \(p, acr) + if (length(p$problems)) paste0(acr, ": ", p$problems))) + + # The Introduction tree, for the CI rows of the cross-check only. + ci <- cybok_tree_tables(cybok_parse_tree(file.path(staging, intro_tree)), "CI") + tree_problems <- c(tree_problems, if (nrow(ci$topics) == 0) "CI: no Topics found") + + atoz_res <- cybok_resolve_atoz(atoz, bind_rows(topics, ci$topics), bind_rows(im, ci$im)) + + # Documented verbatim-check exceptions (owner decision 2026-09-21): applied + # to im$term only after the A-to-Z cross-check above, so resolution matching + # still sees both extractions' actual (space-dropped) strings. + verbatim_exceptions <- cybok_read_verbatim_exceptions( + file.path(staging, "tables", "verbatim-exceptions.csv")) + im <- cybok_apply_verbatim_exceptions(im, verbatim_exceptions) + + # Verbatim check. + refs <- set_names(map(file.path(staging, check_of$filename), read_file), check_of$text_of) + tree_of <- function(acr) kas$tree_file[match(acr, kas$ka_acronym)] + checks <- bind_rows( + tibble(field = "category_name", id = as.character(categories$category_order), + text = categories$category_name, source_file = intro_file), + tibble(field = "ka_name", id = kas$ka_acronym, text = kas$ka_name, + source_file = intro_file), + tibble(field = "topic_title", id = topics$topic_id, text = topics$title, + source_file = tree_of(topics$ka_acronym)), + tibble(field = "indicative_material", id = paste0(im$topic_id, ".", im$ordinal), + text = im$term, source_file = tree_of(im$ka_acronym)) + ) + check <- cybok_verbatim_check(checks, refs, verbatim_exceptions) + + # Crosswalks staged earlier in this directory. + xw_sfia <- read_csv(file.path(staging, "tables", "cybok-sfia-mapping.csv"), + show_col_types = FALSE) + xw_ecsf <- read_csv(file.path(staging, "tables", "cybok-ecsf-mapping.csv"), + show_col_types = FALSE) + xw <- bind_rows( + cybok_resolve_crosswalk_kas(xw_sfia$ka, kas) |> mutate(crosswalk = "sfia", .before = 1), + cybok_resolve_crosswalk_kas(setdiff(xw_ecsf$primary_ka, "N/A"), kas) |> + mutate(crosswalk = "ecsf", .before = 1) + ) + + # Checks that stop the ingest. + problems <- c( + if (nrow(categories) != 5) paste("categories:", nrow(categories), "vs the Introduction's five"), + if (nrow(fig2$kas) != 21) paste("Figure 2 KAs:", nrow(fig2$kas), "vs the Introduction's 21"), + if (any(is.na(fig2$kas$category_name))) "a Figure 2 KA sits above the first category", + if (nrow(kas) != 21) paste("declared KA trees:", nrow(kas), "vs 21"), + if (any(is.na(kas$ka_order))) paste("declared KA name not in Figure 2:", + paste(kas$ka_name[is.na(kas$ka_order)], collapse = ", ")), + if (any(is.na(kas$az_ka_name))) paste("declared acronym not in the A-to-Z:", + paste(kas$ka_acronym[is.na(kas$az_ka_name)], collapse = ", ")), + if (anyDuplicated(kas$ka_acronym) || anyDuplicated(kas$ka_name)) "a KA is declared twice", + if (!all(atoz$ka_acronym %in% acronyms$acronym)) + "an A-to-Z row carries an acronym missing from its acronym table", + if (any(!nzchar(atoz$indicative_material) | !nzchar(atoz$topic))) "an A-to-Z row has an empty cell", + if (anyDuplicated(topics$topic_id)) "duplicate Topic ids", + if (any(!kas$ka_acronym %in% topics$ka_acronym)) "a tree without Topics", + tree_problems + ) + + tables_dir <- file.path(staging, cybok_config$tables_subdir) + dir.create(tables_dir, showWarnings = FALSE, recursive = TRUE) + write_csv(categories, file.path(tables_dir, "categories.csv")) + write_csv(kas |> select(ka_acronym, ka_name, category_name, ka_order, version, + tree_title, az_ka_name, tree_file), + file.path(tables_dir, "knowledge-areas.csv")) + write_csv(topics, file.path(tables_dir, "topics.csv")) + write_csv(im, file.path(tables_dir, "indicative-material.csv")) + write_csv(atoz |> select(row, page, indicative_material, topic, ka_acronym), + file.path(tables_dir, "a-to-z.csv")) + write_csv(atoz_res |> select(row, page, indicative_material, topic, ka_acronym, + topic_id, tree_topic, term_in_tree, term_topic_in_tree, + status), + file.path(tables_dir, "a-to-z-resolution.csv"), na = "") + write_csv(check, file.path(tables_dir, "verbatim-check.csv")) + write_csv(xw, file.path(tables_dir, "crosswalk-ka-resolution.csv"), na = "") + + if (length(problems) > 0) { + stop("CyBOK extraction failed its checks:\n ", paste(problems, collapse = "\n ")) + } + + is_ci <- atoz_res$ka_acronym == "CI" + ka_rows <- atoz_res[!is_ci, ] + # The measured counts below are rewritten on every run. Only the declared + # extraction method and the crosswalks' own counts are carried over, so a + # count this script no longer measures cannot linger in the manifest. + keep <- c("tool", "text_normalisation", "cybok_sfia_mapping", "cybok_ecsf_mapping") + manifest$extraction <- manifest$extraction[intersect(keep, names(manifest$extraction))] + manifest$extraction$categories <- nrow(categories) + manifest$extraction$knowledge_areas <- nrow(kas) + manifest$extraction$topics <- nrow(topics) + manifest$extraction$indicative_material <- nrow(im) + # A Topic the tree draws with no children (Applied Cryptography's "Future + # of Applied Cryptography", which the A-to-Z lists as "CELL LEFT + # DELIBERATELY BLANK"). + manifest$extraction$topics_without_indicative_material <- sum(!topics$topic_id %in% im$topic_id) + manifest$extraction$a_to_z_rows <- nrow(atoz) + manifest$extraction$a_to_z_rows_knowledge_areas <- nrow(ka_rows) + manifest$extraction$a_to_z_rows_resolved <- sum(ka_rows$status == "resolved") + manifest$extraction$a_to_z_status <- as.list(table(ka_rows$status)) + manifest$extraction$a_to_z_rows_introduction <- sum(is_ci) + manifest$extraction$a_to_z_rows_introduction_resolved <- + sum(atoz_res$status[is_ci] == "resolved") + manifest$extraction$verbatim_checked <- nrow(check) + manifest$extraction$verbatim_found <- sum(check$found) + manifest$extraction$crosswalk_ka_names_resolved <- sum(xw$resolution != "unresolved") + manifest$extraction$crosswalk_ka_names_unresolved <- sum(xw$resolution == "unresolved") + write_yaml(manifest, manifest_path) + + message("\n=== Summary ===") + message(" Categories: ", nrow(categories), " Knowledge Areas: ", nrow(kas), + " Topics: ", nrow(topics), " Indicative Material: ", nrow(im)) + message(" A-to-Z rows: ", nrow(atoz), ", of which ", nrow(ka_rows), " name a KA:") + st <- table(ka_rows$status) + message(paste0(" ", names(st), ": ", st, collapse = "\n")) + message(" Verbatim check: ", sum(check$found), " of ", nrow(check), " found") + message("\nDone.") + + invisible(list(kas = kas, topics = topics, im = im, atoz = atoz_res, check = check)) +} + +if (sys.nframe() == 0) { + main() +} diff --git a/scripts/010-ingest-digcomp.R b/scripts/010-ingest-digcomp.R index a080c0d..5d6a145 100644 --- a/scripts/010-ingest-digcomp.R +++ b/scripts/010-ingest-digcomp.R @@ -1,36 +1,55 @@ # 010-ingest-digcomp.R # -# Ingest the European Digital Competence Framework for Citizens (DigComp 2.2) -# from the JRC Publications Office PDF. +# Ingest the European Digital Competence Framework for Citizens, DigComp +# 3.0, replacing DigComp 2.2 in place (owner decision D1, 2026-08-21: a +# framework upgrade, not a second framework alongside the old one). # # Source: -# - DigComp-2.2-JRC128415.pdf (JRC Publications Repository JRC128415) -# - Intermediate: extracted-text.md (via markitdown) +# - The official DigComp 3.0 data supplement (JSON-LD + XLSX), staged +# under data/raw/digcomp/v3.0/. Hash-anchored in +# data/raw/digcomp/v3.0/provenance.yml. Never fetched live. +# - Intermediate: data/raw/digcomp/v3.0/tables/*.csv, already parsed +# verbatim from the JSON-LD's `@graph` (see that provenance.yml's +# `source$conversion_tool`). This ingester verifies the JSON-LD's +# recorded hash, then reads the staged tables the same way +# 010-ingest-otccf.R reads its extracted-text.md intermediate rather +# than re-parsing the PDF on every run. # - Publisher: European Commission Joint Research Centre (JRC) -# - Published: 2022-03-17, Version 2.2 +# - Published: 2025-11-27, Fifth edition, JRC144121, doi:10.2760/0001149 +# (report), doi:10.2905/JRC.FR75K8R (dataset) # -# Licensing: EU open re-use policy. Verify specific terms at JRC Publications -# Repository before redistribution. Analytical derivatives publishable with -# attribution. +# Licensing: CC BY 4.0. Verified three ways in +# data/raw/digcomp/v3.0/provenance.yml. Attribution target: Cosgrove & +# Cachia (2025), DigComp 3.0, EUR 40491, doi:10.2760/0001149; dataset +# doi:10.2905/JRC.FR75K8R. # -# Structure (confirmed from extracted TOC): -# 5 competence areas: -# 1. Information and data literacy (3 competences) -# 2. Communication and collaboration (6 competences) -# 3. Digital content creation (4 competences) -# 4. Safety (4 competences) -- cybersec-relevant area -# 5. Problem solving (4 competences) -# 21 competences total (numbered X.Y format). -# Each competence has 8 proficiency levels with descriptors. +# Structure (from MAPPING-2.2-to-3.0.md and the data supplement): +# 5 competence areas, 21 competences, identical numbering and area +# membership to 2.2 (see data/raw/digcomp/v3.0/MAPPING-2.2-to-3.0.md). +# 4/5 area names and 13/21 competence names revised. 362 Competence +# Statements (coarse, per competence x 4-level proficiency band) and 523 +# Learning Outcomes (fine-grained, typed Knowledge/Skill/Attitude) +# replace 2.2's un-staged "examples of knowledge, skills and attitudes". +# Proficiency levels (8-level descriptors with a 4-level and a +# CEFR-style 6-level crosswalk) and a 126-term glossary are staged but +# not emitted into the graph (see build_digcomp_parts() in +# scripts/020-assemble-jsonld.R) -- deferred, not lost. # -# Extraction scope for this ingester (best-effort): -# - 5 Competence Areas as structural roots -# - 21 Competences with their descriptive titles -# - Proficiency-level descriptors NOT automated (fragmented in markitdown -# output across multiple columns). Marked as future enrichment. +# Errata: the supplement (24 Nov 2025) predates the official errata +# (updated 2026-01-27). data/raw/digcomp/v3.0/errata.csv lists 7 +# data-affecting corrections. This ingester applies E1-E4 deliberately +# (see apply_learning_outcome_errata() below). After errata: 522 learning +# outcomes (competence 2.5 drops from 21 to 20). E5-E7 (LO4.3.22, +# LO5.2.12, LO5.4.13) are published as instructions without a quoted +# replacement sentence, so they are left unapplied -- the staged file's +# wording is carried unchanged rather than composing new sentence text in +# JRC's name. See docs/ingestion-summary.md. # -# DigComp rounds out the EU-side pedagogical coverage as a citizen-oriented -# digital-competence framework, complementing ECSF on the workforce side. +# 2.2 raw stays archived at data/raw/digcomp/v2.2/ (not deleted). This +# script's OUTPUT tables replace data/raw/digcomp/tables/ and +# data/raw/digcomp/provenance.yml in place, per +# scripts/020-assemble-jsonld.R::read_framework_table()'s fixed +# data/raw//tables/ convention. # # Run: Rscript scripts/010-ingest-digcomp.R @@ -49,178 +68,169 @@ suppressPackageStartupMessages({ source(here("scripts", "_ingest-common.R"), local = TRUE) digcomp_config <- list( - framework_version = "DigComp 2.2", - version_date = "2022-03-17", + framework_version = "DigComp 3.0", + version_date = "2025-11-27", publisher = "European Commission Joint Research Centre (JRC)", - pdf_filename = "DigComp-2.2-JRC128415.pdf", - text_filename = "extracted-text.md", + source_dir = here("data", "raw", "digcomp", "v3.0"), + source_tables_dir = here("data", "raw", "digcomp", "v3.0", "tables"), + jsonld_filename = "DigComp-3.0-Data-Supplement-24-Nov-2025.jsonld", + errata_filename = "errata.csv", staging_dir = here("data", "raw", "digcomp"), tables_subdir = "tables", manifest_filename = "provenance.yml", - license = "CC BY 4.0 (European Union, 2022; Commission Decision 2011/833/EU)" -) - -# Closed vocabulary of 5 competence areas and 21 competences. -# Hand-curated from the DigComp 2.2 TOC (verified 2026-04-23). -digcomp_areas <- tibble::tribble( - ~area_id, ~area_number, ~area_name, - "AREA-INFO", 1, "Information and data literacy", - "AREA-COMM", 2, "Communication and collaboration", - "AREA-CONTENT", 3, "Digital content creation", - "AREA-SAFETY", 4, "Safety", - "AREA-PROB", 5, "Problem solving" -) - -digcomp_competences <- tibble::tribble( - ~competence_id, ~area_id, ~competence_name, - "1.1", "AREA-INFO", "Browsing, searching and filtering data, information and digital content", - "1.2", "AREA-INFO", "Evaluating data, information and digital content", - "1.3", "AREA-INFO", "Managing data, information and digital content", - "2.1", "AREA-COMM", "Interacting through digital technologies", - "2.2", "AREA-COMM", "Sharing through digital technologies", - "2.3", "AREA-COMM", "Engaging citizenship through digital technologies", - "2.4", "AREA-COMM", "Collaborating through digital technologies", - "2.5", "AREA-COMM", "Netiquette", - "2.6", "AREA-COMM", "Managing digital identity", - "3.1", "AREA-CONTENT", "Developing digital content", - "3.2", "AREA-CONTENT", "Integrating and re-elaborating digital content", - "3.3", "AREA-CONTENT", "Copyright and licences", - "3.4", "AREA-CONTENT", "Programming", - "4.1", "AREA-SAFETY", "Protecting devices", - "4.2", "AREA-SAFETY", "Protecting personal data and privacy", - "4.3", "AREA-SAFETY", "Protecting health and well-being", - "4.4", "AREA-SAFETY", "Protecting the environment", - "5.1", "AREA-PROB", "Solving technical problems", - "5.2", "AREA-PROB", "Identifying needs and technological responses", - "5.3", "AREA-PROB", "Creatively using digital technology", - "5.4", "AREA-PROB", "Identifying digital competence gaps" + license = "CC BY 4.0 (European Union, 2025; Commission Decision 2011/833/EU)" ) # --------------------------------------------------------------------------- -# Extraction +# Errata # --------------------------------------------------------------------------- -#' Extract the descriptive paragraph following each competence header in the -#' body of the framework document. +#' Apply the 7 official DigComp 3.0 errata to the staged learning outcomes #' -#' DigComp 2.2 has section blocks like "1.1 Browsing, searching..." as the -#' body heading, followed by a descriptive paragraph. We locate the body -#' instance (not the TOC entry) and take the first non-trivial paragraph. -extract_competence_descriptions <- function(text_file_path) { - lines <- read_lines(text_file_path) - - description_rows <- list() - - for (i in seq_len(nrow(digcomp_competences))) { - comp <- digcomp_competences[i, ] - - # Build header pattern for both TOC and body. The body usually has - # "X.Y CompetenceName" followed by descriptive paragraphs. - # Body instances appear after line 400 (past TOC and intro). - heading_pattern <- paste0("^", str_replace_all(comp$competence_id, "\\.", "\\\\."), - "\\s+") - matches <- which(str_detect(lines, heading_pattern)) - body_matches <- matches[matches > 400] - - description <- NA_character_ - - if (length(body_matches) > 0) { - start <- body_matches[1] - # Grab the next ~30 lines, skipping blanks, take first non-trivial - window <- lines[start:min(start + 30, length(lines))] - # Remove the heading line itself - window <- window[-1] - # Filter blanks and trivial lines - non_blank <- window[str_trim(window) != "" & - str_length(str_trim(window)) > 30] - if (length(non_blank) > 0) { - description <- non_blank[1] |> str_squish() - } +#' @description +#' Three corrections (E2-E4) reword a single outcome's text using the +#' exact replacement text the errata document quotes verbatim. Each is +#' applied by exact `outcome_id` match, before any renumbering, so a later +#' renumbering pass never has to track a moving target. +#' +#' E5, E6 and E7 (LO4.3.22, LO5.2.12, LO5.4.13) are published as +#' instructions naming only the phrase to insert, with no replacement +#' sentence quoted. Composing a new sentence from that instruction would +#' put cybedtools's words in JRC's mouth rather than JRC's own text, so +#' these three are left unapplied: the staged file's wording is carried +#' through unchanged. They are recorded as known-unapplied in the +#' provenance manifest and in docs/ingestion-summary.md. +#' +#' The seventh correction (E1) is structural: LO2.5.09 is a verbatim +#' duplicate of LO2.5.07 and is deleted, and competence 2.5's remaining +#' outcomes (originally 2.5.10-2.5.21) are renumbered down by one so 2.5 +#' ends at 20 outcomes with no numbering gap. The official erratum text +#' ("...LOs from 2.5.09 to 2.5.21 should be re-numbered to 2.5.08 to +#' 2.5.20...") is internally inconsistent with deleting 09 (that would +#' renumber 10-21, not 09-21); this resolves it the way +#' data/raw/digcomp/v3.0/errata.csv's own note does: one duplicate +#' removed, 20 outcomes remain in 2.5, 523 -> 522 overall. +#' +#' @param outcomes Tibble read from `learning-outcomes.csv`. +#' @return The corrected tibble, 522 rows. +apply_learning_outcome_errata <- function(outcomes) { + reword <- function(df, id, new_text) { + idx <- which(df$outcome_id == id) + if (length(idx) != 1L) { + stop("Erratum target not found or not unique: ", id) } + df$description[idx] <- new_text + df + } - description_rows[[i]] <- tibble( - element_id = paste0("COMP-", comp$competence_id), - competence_id = comp$competence_id, - area_id = comp$area_id, - competence_name = comp$competence_name, - description = description - ) + outcomes <- outcomes |> + reword("LO2.5.15", paste( + "Respond with effective and respectful communication and behaviour", + "to difficult or complex situations in digital environments." + )) |> + reword("LO2.6.27", paste( + "Stay informed about developments in digital technologies in", + "relation to digital identity management and protection." + )) |> + reword("LO4.3.18", paste( + "Describe strategies to help protect against and respond", + "effectively to harmful behaviour, content and deceptive design in", + "digital environments." + )) + # E5, E6 and E7 (LO4.3.22, LO5.2.12, LO5.4.13) are published as + # instructions naming only a phrase to insert, with no replacement + # sentence quoted. Composing new sentence text from that instruction + # would be cybedtools's words, not JRC's, so these three are left + # unapplied and the staged file's wording carries through verbatim. + # See docs/ingestion-summary.md and the errata block in + # write_provenance_manifest() below. + + dup_idx <- which(outcomes$outcome_id == "LO2.5.09") + if (length(dup_idx) != 1L) { + stop("Erratum E1 target not found or not unique: LO2.5.09") } + outcomes <- outcomes[-dup_idx, ] + + is_25 <- outcomes$competence_id == "2.5" + suffix <- as.integer(str_extract(outcomes$outcome_id, "(?<=\\.)[0-9]+$")) + shift <- is_25 & suffix > 9L + outcomes$outcome_id[shift] <- sprintf("LO2.5.%02d", suffix[shift] - 1L) - bind_rows(description_rows) + outcomes |> arrange(competence_id, outcome_id) } # --------------------------------------------------------------------------- # Provenance # --------------------------------------------------------------------------- -write_provenance_manifest <- function(pdf_path, text_path, areas, competences, descriptions) { +write_provenance_manifest <- function(source_prov, jsonld_sha256, tables) { manifest_path <- file.path(digcomp_config$staging_dir, digcomp_config$manifest_filename) - pdf_sha256 <- if (file.exists(pdf_path)) { - digest(file = pdf_path, algo = "sha256") - } else { - NA_character_ - } - text_sha256 <- digest(file = text_path, algo = "sha256") - retrieved_date <- resolve_retrieved_date( - manifest_path, c(pdf_sha256 = pdf_sha256, text_sha256 = text_sha256)) + hashes <- c(jsonld_sha256 = jsonld_sha256) + retrieved_date <- resolve_retrieved_date(manifest_path, hashes) manifest <- list( framework = "DigComp", framework_version = digcomp_config$framework_version, version_date = digcomp_config$version_date, + edition = source_prov$edition, + authors = source_prov$authors, source = list( - type = "pdf_with_markdown_intermediate", + type = "structured_data_supplement", publisher = digcomp_config$publisher, - pdf_filename = digcomp_config$pdf_filename, - text_filename = digcomp_config$text_filename, - jrc_id = "JRC128415", - conversion_tool = "markitdown MCP (PDF -> markdown)" + jsonld_filename = digcomp_config$jsonld_filename, + jrc_id = source_prov$source$jrc_id, + isbn_online = source_prov$source$isbn_online, + doi_report = source_prov$source$doi_report, + doi_dataset = source_prov$source$doi_dataset, + conversion_tool = "Rscript scripts/010-ingest-digcomp.R: reads data/raw/digcomp/v3.0/tables/ (verbatim JSON-LD @graph extraction, hash-verified against the supplement below), applies errata.csv" ), retrieval = list( - retrieved_date = retrieved_date, - retrieved_by = "scripts/010-ingest-digcomp.R", - pdf_size_bytes = if (file.exists(pdf_path)) file.info(pdf_path)$size else NA_integer_, - pdf_sha256 = pdf_sha256, - text_sha256 = text_sha256 + retrieved_date = retrieved_date, + retrieved_by = "scripts/010-ingest-digcomp.R", + jsonld_sha256 = jsonld_sha256 ), extraction = list( - competence_areas = nrow(areas), - competences = nrow(competences), - descriptions_found = sum(!is.na(descriptions$description)), - extraction_scope = "best-effort: areas + competences (hand-curated from TOC) + first-paragraph description. Proficiency-level descriptors not automated." + competence_areas = nrow(tables$areas), + competences = nrow(tables$competences), + competence_statements = nrow(tables$statements), + learning_outcomes_pre_errata = 523L, + learning_outcomes_post_errata = nrow(tables$outcomes), + proficiency_levels_staged = nrow(tables$proficiency_levels), + glossary_terms_staged = nrow(tables$glossary), + extraction_scope = "complete: every competence-area, competence, competence-statement and (post-errata) learning-outcome row from the official JSON-LD data supplement. Proficiency levels and glossary are staged verbatim but not emitted into the graph (deferred, see scripts/020-assemble-jsonld.R)." + ), + errata = list( + status = "PARTIALLY_APPLIED", + errata_file = "data/raw/digcomp/v3.0/errata.csv", + applied = c("E1", "E2", "E3", "E4"), + unapplied = c("E5", "E6", "E7"), + note = "E1 (structural: delete LO2.5.09, renumber 2.5's outcomes down by one) and E2/E3/E4 (exact quoted replacement text) applied verbatim. Errata E5 to E7 are published as instructions without replacement text, so the staged wording is carried unchanged. The learning-outcome count (522) is unaffected: it comes from E1 alone." ), licensing = list( source_license = digcomp_config$license, citation = paste( - "Vuorikari, R., Kluzer, S. and Punie, Y., DigComp 2.2: The Digital", - "Competence Framework for Citizens, EUR 31006 EN, Publications Office", - "of the European Union, Luxembourg, 2022, ISBN 978-92-76-48882-8,", - "doi:10.2760/115376, JRC128415." + "Cosgrove, J. and Cachia, R., DigComp 3.0: The Digital Competence", + "Framework for Citizens, EUR 40491, Publications Office of the", + "European Union, Luxembourg, 2025, ISBN 978-92-68-32677-0,", + "doi:10.2760/0001149. Dataset: doi:10.2905/JRC.FR75K8R." ), redistribution_note = paste( - "CC BY 4.0 by the source PDF's own imprint page: reuse is allowed", - "provided appropriate credit is given and any changes are indicated.", - "The 'except otherwise noted' qualifier and the photo carve-out do not", - "reach anything this script ingests. Use the prescribed citation above." + "CC BY 4.0, verified three ways in", + "data/raw/digcomp/v3.0/provenance.yml: the dataset's own", + "copyright.txt, the JRC Data Catalogue record, and the DigComp 3.0", + "resources page. The European Commission logo is excluded from", + "reuse; cybedtools reproduces no logo." ) ), notes = list( framework_type = "pedagogical digital-competence framework for citizens", - extraction_limitations = paste( - "Markitdown PDF conversion produces column-flattened output.", - "Hand-curated 5 areas + 21 competences from the TOC; body", - "descriptions extracted as first substantial paragraph after each", - "competence heading. Full proficiency-level descriptors (8 levels", - "per competence) would require manual curation or table-aware PDF", - "extraction (Tabula, pdfplumber) on the original PDF." - ), + replaces = "DigComp 2.2 (owner decision D1, 2026-08-21: replace in place, do not keep both). 2.2 raw stays archived at data/raw/digcomp/v2.2/.", + unit_grain = "2.2 used only the 5 competence areas as cybed:OrganizingUnit, with the 21 competences as cybed:RoleElement. 3.0 moves elements down to the 362 competence statements, so the 21 competences become a second organizing-unit tier (cybed:Competence) alongside the 5 areas (cybed:CompetenceArea) -- 26 organizing units total, matching the 26 IRIs (5 areas + 21 competences) that data/raw/digcomp/v3.0/MAPPING-2.2-to-3.0.md's survival analysis identifies as surviving unchanged from 2.2.", cybersec_relevance = paste( - "Competence Area 4 (Safety) is the most cybersec-adjacent: 4.1", - "Protecting devices, 4.2 Protecting personal data and privacy,", - "4.3 Protecting health and well-being, 4.4 Protecting the", - "environment. The schema represents all 21 competences across", - "all five areas, not only the cybersec-relevant Safety area." + "Competence Area 4, renamed 'Safety, wellbeing and responsible", + "use', remains the most cybersec-adjacent: 4.1 Protecting devices,", + "4.2 Protecting personal data and privacy, 4.3 Supporting", + "wellbeing, 4.4 Environmental impacts of digital technologies." ) ) ) @@ -235,31 +245,69 @@ write_provenance_manifest <- function(pdf_path, text_path, areas, competences, d # --------------------------------------------------------------------------- main <- function() { - message("=== DigComp 2.2 Ingestion ===") + message("=== DigComp 3.0 Ingestion (replacing DigComp 2.2 in place) ===") - pdf_path <- file.path(digcomp_config$staging_dir, digcomp_config$pdf_filename) - text_path <- file.path(digcomp_config$staging_dir, digcomp_config$text_filename) + source_prov <- read_yaml(file.path(digcomp_config$source_dir, "provenance.yml")) - if (!file.exists(text_path)) stop("Extracted markdown missing at ", text_path) + jsonld_path <- file.path(digcomp_config$source_dir, digcomp_config$jsonld_filename) + if (!file.exists(jsonld_path)) stop("Data supplement missing at ", jsonld_path) + jsonld_sha256 <- digest(file = jsonld_path, algo = "sha256") + expected_sha256 <- source_prov$retrieval$jsonld_sha256 + if (!identical(jsonld_sha256, expected_sha256)) { + stop( + "DigComp 3.0 data supplement hash mismatch.\n expected: ", expected_sha256, + "\n actual: ", jsonld_sha256, + "\nThis is a human-review event, not a tolerance to absorb: re-verify ", + "the staged supplement against data/raw/digcomp/v3.0/provenance.yml ", + "before re-running." + ) + } + message("Data supplement hash verified: ", jsonld_sha256) + + read_source_table <- function(name) { + read_csv(file.path(digcomp_config$source_tables_dir, paste0(name, ".csv")), + show_col_types = FALSE) + } + + areas <- read_source_table("competence-areas") + area_descs <- read_source_table("competence-area-descriptions") + competences <- read_source_table("competences") + comp_descs <- read_source_table("competence-descriptions") + statements <- read_source_table("competence-statements") + outcomes_raw <- read_source_table("learning-outcomes") + proficiency <- read_source_table("proficiency-levels") + glossary <- read_source_table("glossary") - message("Extracting competence descriptions...") - descriptions <- extract_competence_descriptions(text_path) - message(" Descriptions located: ", sum(!is.na(descriptions$description)), " / ", - nrow(descriptions)) + message("Applying learning-outcome errata (data/raw/digcomp/v3.0/errata.csv)...") + outcomes <- apply_learning_outcome_errata(outcomes_raw) + message(" Learning outcomes: ", nrow(outcomes_raw), " staged -> ", + nrow(outcomes), " after errata") tables_dir <- file.path(digcomp_config$staging_dir, digcomp_config$tables_subdir) dir.create(tables_dir, showWarnings = FALSE, recursive = TRUE) - write_csv(digcomp_areas, file.path(tables_dir, "competence-areas.csv")) - write_csv(digcomp_competences, file.path(tables_dir, "competences.csv")) - write_csv(descriptions, file.path(tables_dir, "competence-descriptions.csv")) + write_csv(areas, file.path(tables_dir, "competence-areas.csv")) + write_csv(area_descs, file.path(tables_dir, "competence-area-descriptions.csv")) + write_csv(competences, file.path(tables_dir, "competences.csv")) + write_csv(comp_descs, file.path(tables_dir, "competence-descriptions.csv")) + write_csv(statements, file.path(tables_dir, "competence-statements.csv")) + write_csv(outcomes, file.path(tables_dir, "learning-outcomes.csv")) + write_csv(proficiency, file.path(tables_dir, "proficiency-levels.csv")) + write_csv(glossary, file.path(tables_dir, "glossary.csv")) - write_provenance_manifest(pdf_path, text_path, digcomp_areas, digcomp_competences, descriptions) + write_provenance_manifest( + source_prov, jsonld_sha256, + list(areas = areas, competences = competences, statements = statements, + outcomes = outcomes, proficiency_levels = proficiency, glossary = glossary) + ) message("\n=== Summary ===") - message(" Competence areas: ", nrow(digcomp_areas)) - message(" Competences: ", nrow(digcomp_competences)) - message(" Descriptions: ", sum(!is.na(descriptions$description))) + message(" Competence areas: ", nrow(areas)) + message(" Competences: ", nrow(competences)) + message(" Competence statements: ", nrow(statements)) + message(" Learning outcomes: ", nrow(outcomes), " (post-errata)") + message(" Proficiency levels staged (not emitted): ", nrow(proficiency)) + message(" Glossary terms staged (not emitted): ", nrow(glossary)) message("\nDone.") } diff --git a/scripts/010-ingest-nice.R b/scripts/010-ingest-nice.R index d5905ae..d259de8 100644 --- a/scripts/010-ingest-nice.R +++ b/scripts/010-ingest-nice.R @@ -313,8 +313,8 @@ write_provenance_manifest <- function(json_path, file = "errata.csv", applied_count = nrow(nice_errata), note = paste( - "Source-data corrections applied deterministically at ingest;", - "see errata.csv for element ids, published vs corrected values,", + "Source-data corrections applied deterministically at ingest.", + "See errata.csv for element ids, published vs corrected values,", "and rationale." ) ), diff --git a/scripts/010-ingest-scywf.R b/scripts/010-ingest-scywf.R new file mode 100644 index 0000000..aa0c449 --- /dev/null +++ b/scripts/010-ingest-scywf.R @@ -0,0 +1,797 @@ +# 010-ingest-scywf.R +# +# Ingest the Saudi Cybersecurity Workforce Framework (SCyWF – 1.5: 2026) from +# the National Cybersecurity Authority (NCA) PDF. +# +# Source: scywf_en.pdf (128 pages), staged under data/raw/scywf/ with a +# provenance.yml that records the SHA256 of every staged file. This script +# never fetches anything, and it stops unless every declared file is present +# with its declared hash. +# +# Licensing. NCA granted written permission on 2026-09-20. The terms are +# binding on this script: NCA is cited as issuing body and source with the +# official page link, and everything taken from the document is carried +# verbatim. Identifiers, titles and statement text are copied exactly as +# published. The only edits are the two layout normalisations documented +# below. Nothing is composed, normalised, paraphrased or translated. Anything +# cybedtools derives (the hierarchy edges between job roles, specialty areas +# and categories) is ours and is never presented as NCA content. +# +# Text normalisation, the complete list: +# 1. Whitespace. Every run of whitespace, including a line break inside a +# table cell, becomes one space. Leading and trailing whitespace goes. +# 2. Line-break hyphenation. A line break that directly follows a hyphen is +# removed without inserting a space, and the hyphen is kept. Every such +# join is written to tables/hyphen-joins.csv for review. +# No other character is changed: apostrophes, quotation marks, capitals and +# punctuation stay as the PDF encodes them. +# +# Structure (observed in the PDF, not assumed): +# Appendix A (pp. 19 to 42) one card per job role. Fields: Job Role Name, +# Job Role ID, Category, Specialty Area, Job Role Description, +# Competency Areas, and Task, Knowledge and Skill CODES only. +# Appendix B (pp. 43 to 122) Tables 9, 10 and 11: the Task, Knowledge and +# Skill statements by code. Skills carry a published "Technical?" flag. +# Appendix C (pp. 123 to 125) Table 12: competency areas by CA code, with +# name and description. +# The document states its own size (p. 9): five categories, twelve specialty +# areas and forty job roles. Job role IDs are category ID, specialty area ID +# and a sequence number (CARD-CA-001). +# +# Extraction is table-aware. pdftools::pdf_data() gives every word with its +# page coordinates. Cells are rebuilt from the coordinates: a card's value +# column starts at the x of its Job Role ID, and multi-line table rows are +# assigned to their row anchor (the code in the first column) by a monotone +# partition that minimises the distance between each anchor and the vertical +# centre of its lines. A line-oriented regex over the linear text dump cannot +# do this: it finds only 26 of the 40 role IDs. +# +# Verification is independent of the extractor. scywf_en_raw.txt is the same +# PDF read by a second program (xpdf pdftotext -raw -enc UTF-8, content-stream +# order). Every carried string is looked up in that text after the same two +# normalisations. The result is written to tables/verbatim-check.csv and the +# ingest stops unless every string is found. +# +# Not ingested this pass: the companion Career Progression document +# (scywf_cp_en.pdf, SCyWF-CP 1:2026), which adds career levels, minimum +# qualifications and progression pathways. It is staged for provenance and +# deferred. The category and specialty area descriptions of Tables 1 and 2 +# are not carried either: those tables set text in merged, vertically +# centred cells that this extractor does not yet rebuild reliably. +# +# Tables written to data/raw/scywf/tables/: +# categories.csv one row per category (5) +# specialty-areas.csv one row per specialty area (12) +# roles.csv one row per job role card (40) +# statements.csv one row per Appendix B statement +# role-statements.csv one row per code printed on a role card +# competency-areas.csv one row per Appendix C competency area +# role-competency-areas.csv one row per CA code printed on a role card +# unresolved-codes.csv card codes with no Appendix B statement +# hyphen-joins.csv every line-break hyphenation join applied +# verbatim-check.csv the verbatim check, one row per carried string +# nice-overlap.csv statement codes shared with NICE, and whether +# the text is identical (a finding, not a merge) +# nice-same-text.csv SCyWF statements worded exactly as a NICE +# statement, under whatever code +# +# Run: Rscript scripts/010-ingest-scywf.R + +suppressPackageStartupMessages({ + library(here) + library(pdftools) + library(dplyr) + library(readr) + library(yaml) + library(purrr) + library(tibble) + library(stringr) + library(digest) +}) + +scywf_config <- list( + staging_dir = here("data", "raw", "scywf"), + pdf_filename = "scywf_en.pdf", + check_filename = "scywf_en_raw.txt", + tables_subdir = "tables", + manifest_filename = "provenance.yml", + nice_tables_dir = here("data", "raw", "nice", "tables"), + # Page furniture: the running header sits at y = 36 and the footer from + # y = 772. Footnotes sit above the footer, at y 749 to 762, and are kept. + header_max_y = 45, + footer_min_y = 768 +) + +scywf_role_id_regex <- "^(CARD|LWD|GRCL|PD|ICSOT)-[A-Z]+-[0-9]{3}$" +scywf_code_regex <- "^[TKS][0-9]{4}$" +scywf_ca_regex <- "^CA[0-9]{3}$" + +# --------------------------------------------------------------------------- +# Provenance anchor +# --------------------------------------------------------------------------- + +#' Stop unless every file the manifest declares matches its recorded hash. +verify_scywf_hashes <- function(manifest, staging_dir) { + files <- manifest$source$files + if (is.null(files) || length(files) == 0) { + stop("provenance.yml declares no source files to verify.") + } + for (f in files) { + path <- file.path(staging_dir, f$filename) + if (!file.exists(path)) stop("Declared source file missing: ", path) + actual <- digest(file = path, algo = "sha256") + if (!identical(actual, f$file_sha256)) { + stop("SHA256 mismatch for ", f$filename, ": declared ", f$file_sha256, + ", found ", actual) + } + } + invisible(TRUE) +} + +# --------------------------------------------------------------------------- +# Text normalisation (the two documented rules, nothing else) +# --------------------------------------------------------------------------- + +#' Join the lines of one table cell into one string. +#' +#' Rule 1: lines are joined with one space and whitespace runs collapse. +#' Rule 2: a line that ends in a hyphen joins the next line with no space. +#' Returns the text and the hyphenation joins applied, for the join log. +scywf_join_lines <- function(lines) { + lines <- str_squish(lines) + lines <- lines[nzchar(lines)] + if (length(lines) == 0) { + return(list(text = "", joins = tibble(left = character(), right = character()))) + } + out <- lines[1] + joins <- tibble(left = character(), right = character()) + for (ln in lines[-1]) { + if (str_detect(out, "[[:alpha:]]-$")) { + joins <- bind_rows(joins, tibble(left = str_extract(out, "\\S+$"), + right = str_extract(ln, "^\\S+"))) + out <- paste0(out, ln) + } else { + out <- paste(out, ln) + } + } + list(text = out, joins = joins) +} + +#' Apply the same two rules to free text, for the verbatim check. +scywf_normalise_text <- function(x) { + x |> + str_replace_all("(?<=[[:alpha:]]-)[ \\t]*\\r?\\n\\s*", "") |> + str_squish() +} + +# --------------------------------------------------------------------------- +# Geometry helpers +# --------------------------------------------------------------------------- + +#' Cluster words into lines by baseline. Words whose y lies within `tol` of +#' the running line's first y join it. Within a line words are x ordered. +scywf_cluster_lines <- function(words, tol = 2) { + if (nrow(words) == 0) { + return(tibble(line = integer(), y = numeric(), x0 = numeric(), text = character())) + } + words <- words |> arrange(y, x) + line_id <- integer(nrow(words)) + current <- 1L + anchor <- words$y[1] + for (i in seq_len(nrow(words))) { + if (words$y[i] - anchor > tol) { + current <- current + 1L + anchor <- words$y[i] + } + line_id[i] <- current + } + words |> + mutate(line = line_id) |> + arrange(line, x) |> + group_by(line) |> + summarise(y = mean(y), x0 = min(x), text = paste(text, collapse = " "), + .groups = "drop") +} + +#' Assign line y positions to row anchors. +#' +#' Rows are contiguous and in anchor order, every anchor owns at least one +#' line, and the partition minimises the squared distance between each +#' anchor and the vertical centre of its lines. Returns the anchor index of +#' each line. +scywf_partition_rows <- function(line_y, anchor_y) { + n_lines <- length(line_y) + n_anchor <- length(anchor_y) + if (n_anchor == 0 || n_lines < n_anchor) { + stop("Cannot partition ", n_lines, " line(s) among ", n_anchor, " row anchor(s).") + } + ord <- order(line_y) + ly <- line_y[ord] + cost <- function(i, j, k) ((ly[i] + ly[j]) / 2 - anchor_y[k])^2 + best <- matrix(Inf, n_anchor, n_lines) + from <- matrix(NA_integer_, n_anchor, n_lines) + for (j in seq_len(n_lines)) best[1, j] <- cost(1, j, 1) + if (n_anchor > 1) { + for (k in 2:n_anchor) { + for (j in k:n_lines) { + for (i in k:j) { + v <- best[k - 1, i - 1] + cost(i, j, k) + if (v < best[k, j]) { + best[k, j] <- v + from[k, j] <- i + } + } + } + } + } + assign <- integer(n_lines) + j <- n_lines + for (k in n_anchor:1) { + i <- if (k == 1) 1L else from[k, j] + assign[i:j] <- k + j <- i - 1L + } + out <- integer(n_lines) + out[ord] <- assign + out +} + +#' Drop small-caps table captions ("TABLE 9: TASKS DESCRIPTIONS"), which the +#' PDF sets on two baselines as "T 9: T D" and "ABLE ASKS ESCRIPTIONS". +scywf_drop_captions <- function(words) { + cap_y <- words$y[words$text == "ABLE"] + if (length(cap_y) == 0) return(words) + words |> filter(!vapply(y, \(v) any(abs(v - cap_y) <= 5), logical(1))) +} + +# --------------------------------------------------------------------------- +# Appendix A: job role cards +# --------------------------------------------------------------------------- + +scywf_label_sequence <- list( + name = c("Job", "Role", "Name"), + id = c("Job", "Role", "ID"), + category = "Category", + specialty = c("Specialty", "Area"), + description = c("Job", "Role", "Description"), + competency = c("Competency", "Areas"), + tasks = "Tasks", + knowledge = "Knowledge", + skills = "Skills" +) + +#' Parse one job role card from the words of its region. +#' +#' @param words Tibble of words (x, y, text) from the card header down to the +#' next card header or the page foot. +#' @return List: role (one-row tibble), codes (tibble), asterisk (logical), +#' footnote_words (words below the card's last code line). +scywf_parse_card <- function(words) { + id_word <- words |> filter(str_detect(text, scywf_role_id_regex)) + if (nrow(id_word) != 1) { + stop("Expected one job role ID in a card region, found ", nrow(id_word)) + } + value_x0 <- id_word$x - 6 + + code_like <- str_detect(str_remove(words$text, ",$"), scywf_code_regex) + last_code_y <- max(words$y[code_like & words$x >= value_x0]) + footnote_words <- words |> filter(y > last_code_y + 8) + words <- words |> filter(y <= last_code_y + 8) + + label_words <- words |> filter(x < value_x0) + value_words <- words |> filter(x >= value_x0) + + # Walk the label column in reading order against the fixed label sequence. + label_words <- label_words |> arrange(y, x) + asterisk <- any(str_detect(label_words$text, "\\*")) + label_words <- label_words |> + mutate(text = str_remove_all(text, "\\*")) |> + filter(nzchar(text)) + expected <- unlist(scywf_label_sequence, use.names = FALSE) + field_of <- rep(names(scywf_label_sequence), lengths(scywf_label_sequence)) + if (!identical(label_words$text, expected)) { + stop("Card label column does not read as the expected sequence: ", + paste(label_words$text, collapse = " ")) + } + label_centres <- tibble(field = field_of, y = label_words$y) |> + group_by(field) |> + summarise(y = (min(y) + max(y)) / 2, .groups = "drop") + centre <- setNames(label_centres$y, label_centres$field) + + lines <- scywf_cluster_lines(value_words) + tokens <- str_split(lines$text, " ") + is_code_line <- vapply(tokens, \(t) all(str_detect(str_remove(t, ",$"), scywf_code_regex)), + logical(1)) + is_id_line <- str_detect(lines$text, scywf_role_id_regex) & + vapply(tokens, length, integer(1)) == 1L + + # Text fields run in a fixed order down the card, one cell each, and each + # label is centred on its cell. The lines are split among the five fields + # by the same monotone partition used for table rows, anchored on the label + # centres. The ID row holds only the ID, so it is not a candidate. + text_fields <- c("name", "category", "specialty", "description", "competency") + text_lines <- lines[!is_code_line & !is_id_line, ] |> arrange(y) + id_line_y <- lines$y[is_id_line] + text_lines$field <- text_fields[scywf_partition_rows(text_lines$y, centre[text_fields])] + if (any(text_lines$field == "name" & text_lines$y > id_line_y) || + any(text_lines$field != "name" & text_lines$y < id_line_y)) { + stop("Card ", id_word$text, ": a text line crosses the Job Role ID row.") + } + + joins <- tibble(field = character(), left = character(), right = character()) + field_text <- function(f) { + j <- scywf_join_lines(text_lines$text[text_lines$field == f]) + if (nrow(j$joins) > 0) joins <<- bind_rows(joins, mutate(j$joins, field = f)) + j$text + } + + # Code lines: letters must run T, then K, then S, top to bottom. + code_lines <- lines[is_code_line, ] + codes <- code_lines |> + mutate(code = str_split(text, " ")) |> + tidyr::unnest(code) |> + mutate(separator = if_else(str_detect(code, ",$"), ",", ""), + code = str_remove(code, ",$"), + statement_type = unname(c(T = "task", K = "knowledge", S = "skill")[substr(code, 1, 1)])) + letter_rank <- match(substr(codes$code, 1, 1), c("T", "K", "S")) + if (is.unsorted(letter_rank)) { + stop("Card ", id_word$text, ": Task, Knowledge and Skill codes are interleaved.") + } + codes <- codes |> + group_by(statement_type) |> + mutate(ordinal = row_number()) |> + ungroup() |> + select(statement_id = code, statement_type, ordinal) + + role <- tibble( + role_id = id_word$text, + role_name = field_text("name"), + category_text = field_text("category"), + specialty_area_text = field_text("specialty"), + description = field_text("description"), + competency_areas_text = field_text("competency") + ) + empty <- names(role)[vapply(role, \(v) !nzchar(v), logical(1))] + if (length(empty) > 0) { + stop("Card ", id_word$text, " has empty field(s): ", paste(empty, collapse = ", ")) + } + + list(role = role, codes = codes, asterisk = asterisk, + footnote_words = footnote_words, joins = mutate(joins, id = id_word$text)) +} + +#' Parse every job role card in the document. +#' +#' @param pages List of pdf_data() tibbles, one per page, header and footer +#' already removed. +scywf_parse_cards <- function(pages) { + roles <- list() + codes <- list() + joins <- list() + for (p in seq_along(pages)) { + w <- pages[[p]] + lines <- scywf_cluster_lines(w) + header_y <- lines$y[lines$text == "Job Role Details"] + if (length(header_y) == 0) next + # A category group heading always precedes its first card, so a card + # region runs from its own header to the next one or to the page foot. + bounds <- c(header_y, Inf) + footnotes <- list() + page_roles <- list() + for (k in seq_along(header_y)) { + region <- w |> filter(y >= bounds[k] - 1, y < bounds[k + 1] - 1) + region <- region |> filter(!(abs(y - bounds[k]) < 2 & + text %in% c("Job", "Role", "Details"))) + card <- scywf_parse_card(region) + card$role$page <- p + page_roles[[k]] <- card + if (nrow(card$footnote_words) > 0) { + fl <- scywf_cluster_lines(card$footnote_words) + # A footnote opens with its "*" marker, set on its own baseline a + # point below the text. Each footnote is one printed line. + fl <- fl |> filter(text != "*") |> mutate(text = str_remove(text, "^\\*\\s*")) + footnotes <- c(footnotes, as.list(fl$text)) + } + } + starred <- which(vapply(page_roles, \(c) c$asterisk, logical(1))) + if (length(starred) != length(footnotes)) { + stop("Page ", p, ": ", length(starred), " starred Competency Areas field(s) but ", + length(footnotes), " footnote(s).") + } + for (k in seq_along(page_roles)) { + card <- page_roles[[k]] + note <- if (k %in% starred) footnotes[[match(k, starred)]] else NA_character_ + roles[[length(roles) + 1]] <- mutate(card$role, competency_areas_note = note) + codes[[length(codes) + 1]] <- mutate(card$codes, role_id = card$role$role_id) + joins[[length(joins) + 1]] <- card$joins + } + } + list(roles = bind_rows(roles), + codes = bind_rows(codes) |> select(role_id, statement_id, statement_type, ordinal), + joins = bind_rows(joins)) +} + +# --------------------------------------------------------------------------- +# Appendix B statements and Appendix C competency areas +# --------------------------------------------------------------------------- + +#' Parse the rows of Table 9, 10 or 11 found on one page. +#' +#' @param words Words of the table body on one page: below the column header, +#' above the next header or the page foot. +#' @param statement_type "task", "knowledge" or "skill". +scywf_parse_statement_page <- function(words, statement_type) { + words <- scywf_drop_captions(words) + if (nrow(words) == 0) { + return(list(rows = tibble(statement_id = character(), statement_type = character(), + text = character(), technical = character()), + joins = tibble())) + } + + # The ID column ends where its codes end. Everything right of it is text. + ids <- words |> filter(x < 110, str_detect(text, scywf_code_regex)) + id_right <- max(ids$x + ids$width) + 3 + stray <- words |> filter(x < id_right, !str_detect(text, scywf_code_regex)) + if (nrow(stray) > 0) { + stop("Unexpected word in the ID column: ", paste(stray$text, collapse = " ")) + } + tech_x <- Inf + flags <- NULL + if (statement_type == "skill") { + tech_x <- 460 + flags <- words |> filter(x >= tech_x) + if (!all(flags$text %in% c("Yes", "No")) || nrow(flags) != nrow(ids)) { + stop("Skill page: Technical? column does not hold one Yes or No per skill.") + } + } + text_words <- words |> filter(x >= id_right, x < tech_x) + lines <- scywf_cluster_lines(text_words) + ids <- ids |> arrange(y) + owner <- scywf_partition_rows(lines$y, ids$y) + + joins <- list() + rows <- map(seq_len(nrow(ids)), function(k) { + j <- scywf_join_lines(lines$text[owner == k][order(lines$y[owner == k])]) + if (nrow(j$joins) > 0) joins[[length(joins) + 1]] <<- mutate(j$joins, id = ids$text[k]) + tibble(statement_id = ids$text[k], statement_type = statement_type, text = j$text) + }) |> bind_rows() + if (!is.null(flags)) { + rows$technical <- (flags |> arrange(y))$text + } else { + rows$technical <- NA_character_ + } + list(rows = rows, joins = bind_rows(joins)) +} + +#' Parse one page of Table 12 (competency areas). +scywf_parse_competency_page <- function(words) { + # Only the first page of Table 12 prints the column header, under the + # appendix's introductory paragraph. Continuation pages start with rows. + header <- words |> filter(text == "Name", x > 120, x < 200) + if (nrow(header) > 0) words <- words |> filter(y > header$y[1] + 3) + words <- scywf_drop_captions(words) + ids <- words |> filter(x < 100) |> arrange(y) + if (!all(str_detect(ids$text, scywf_ca_regex))) { + stop("Unexpected word in the CA ID column: ", paste(ids$text, collapse = " ")) + } + name_lines <- scywf_cluster_lines(words |> filter(x >= 100, x < 225)) + desc_lines <- scywf_cluster_lines(words |> filter(x >= 225)) + name_owner <- scywf_partition_rows(name_lines$y, ids$y) + desc_owner <- scywf_partition_rows(desc_lines$y, ids$y) + joins <- list() + cell <- function(lines, owner, k, field) { + j <- scywf_join_lines(lines$text[owner == k][order(lines$y[owner == k])]) + if (nrow(j$joins) > 0) { + joins[[length(joins) + 1]] <<- mutate(j$joins, id = ids$text[k], field = field) + } + j$text + } + rows <- map(seq_len(nrow(ids)), \(k) tibble( + ca_id = ids$text[k], + ca_name = cell(name_lines, name_owner, k, "ca_name"), + description = cell(desc_lines, desc_owner, k, "ca_description") + )) |> bind_rows() + list(rows = rows, joins = bind_rows(joins)) +} + +# --------------------------------------------------------------------------- +# Derived tables +# --------------------------------------------------------------------------- + +#' Categories and specialty areas, from the IDs inside the job role IDs and +#' the verbatim Category and Specialty Area fields of the cards. Every role +#' in a group must print the same field text, or the ingest stops. +scywf_groups <- function(roles) { + roles <- roles |> + mutate(category_id = str_split_i(role_id, "-", 1), + specialty_area_id = str_split_i(role_id, "-", 2)) + categories <- roles |> + group_by(category_id) |> + summarise(category_name = unique(category_text), + first_role = min(role_id), .groups = "drop") + specialty <- roles |> + group_by(specialty_area_id, category_id) |> + summarise(specialty_area_name = unique(specialty_area_text), + first_role = min(role_id), .groups = "drop") + if (anyDuplicated(categories$category_id) || anyDuplicated(specialty$specialty_area_id)) { + stop("A category or specialty area prints more than one name across its roles.") + } + order_ids <- unique(roles$category_id) + list( + roles = roles, + categories = categories |> + arrange(match(category_id, order_ids)) |> + select(category_id, category_name), + specialty_areas = specialty |> + arrange(match(category_id, order_ids), first_role) |> + select(specialty_area_id, specialty_area_name, category_id) + ) +} + +#' CA codes printed in a card's Competency Areas field, in printed order. +scywf_role_competency_areas <- function(roles) { + roles |> + select(role_id, competency_areas_text) |> + mutate(ca_id = str_extract_all(competency_areas_text, "(?<=\\()CA[0-9]{3}(?=\\))")) |> + tidyr::unnest(ca_id) |> + group_by(role_id) |> + mutate(ordinal = row_number()) |> + ungroup() |> + select(role_id, ca_id, ordinal) +} + +# --------------------------------------------------------------------------- +# Verification +# --------------------------------------------------------------------------- + +#' Look every carried string up in the independent extraction. +#' +#' Plain rows check that the string occurs. Paired rows check a statement +#' together with its code (and a skill's Technical? flag), and a card's code +#' list exactly as printed, which also proves the row partition. +scywf_verbatim_check <- function(roles, statements, codes, competency, categories, + specialty_areas, reference_text) { + ref <- scywf_normalise_text(reference_text) + card_lists <- codes |> + group_by(role_id, statement_type) |> + summarise(text = paste(statement_id, collapse = ", "), .groups = "drop") |> + transmute(field = paste0("card_", statement_type, "_codes"), id = role_id, text) + stmt_pairs <- statements |> + transmute(field = "statement_with_code", id = statement_id, + text = if_else(is.na(technical), paste(statement_id, text), + paste(statement_id, text, technical))) + checks <- bind_rows( + tibble(field = "role_id", id = roles$role_id, text = roles$role_id), + tibble(field = "role_name", id = roles$role_id, text = roles$role_name), + tibble(field = "role_description", id = roles$role_id, text = roles$description), + tibble(field = "role_category", id = roles$role_id, text = roles$category_text), + tibble(field = "role_specialty_area", id = roles$role_id, text = roles$specialty_area_text), + tibble(field = "role_competency_areas", id = roles$role_id, + text = roles$competency_areas_text), + tibble(field = "role_competency_areas_note", + id = roles$role_id[!is.na(roles$competency_areas_note)], + text = roles$competency_areas_note[!is.na(roles$competency_areas_note)]), + tibble(field = "statement_text", id = statements$statement_id, text = statements$text), + stmt_pairs, + card_lists, + tibble(field = "ca_id", id = competency$ca_id, text = competency$ca_id), + tibble(field = "ca_name", id = competency$ca_id, text = competency$ca_name), + tibble(field = "ca_description", id = competency$ca_id, text = competency$description), + tibble(field = "category_name", id = categories$category_id, + text = categories$category_name), + tibble(field = "specialty_area_name", id = specialty_areas$specialty_area_id, + text = specialty_areas$specialty_area_name) + ) + checks |> mutate(found = vapply(text, \(t) grepl(t, ref, fixed = TRUE), logical(1))) +} + +#' Statement codes SCyWF shares with the NICE Framework, and whether the text +#' printed under the code is identical. A finding for the record: the graph +#' keeps the two frameworks' statements apart under their own prefixes. +scywf_nice_overlap <- function(statements, nice_tables_dir) { + paths <- file.path(nice_tables_dir, c("tasks.csv", "knowledge.csv", "skills.csv")) + if (!all(file.exists(paths))) return(NULL) + nice <- map(paths, \(p) read_csv(p, show_col_types = FALSE, + col_types = cols(.default = col_character()))) |> + bind_rows() |> + select(statement_id = element_id, nice_text = text) + shared_code <- statements |> + select(statement_id, statement_type, scywf_text = text) |> + inner_join(nice, by = "statement_id") |> + mutate(identical_text = scywf_text == nice_text, + identical_after_whitespace = str_squish(scywf_text) == str_squish(nice_text)) + # The same wording under any NICE code, whatever the SCyWF code. + same_text <- statements |> + select(statement_id, scywf_text = text) |> + inner_join(nice |> rename(nice_statement_id = statement_id), + by = c("scywf_text" = "nice_text"), relationship = "many-to-many") + attr(shared_code, "same_text_any_code") <- same_text + shared_code +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +scywf_read_pages <- function(pdf_path) { + pdf_data(pdf_path) |> + map(\(w) w |> + filter(y > scywf_config$header_max_y, y < scywf_config$footer_min_y) |> + select(x, y, width, text)) +} + +#' Locate the page ranges of Appendices A, B and C from their own headings. +scywf_locate <- function(pages) { + has_line <- function(p, pattern) any(str_detect(scywf_cluster_lines(p)$text, pattern)) + a <- which(vapply(pages, has_line, logical(1), "Appendix A: Job Role Details$")) + b <- which(vapply(pages, has_line, logical(1), "Appendix B: List of Tasks")) + c <- which(vapply(pages, has_line, logical(1), "Appendix C: List of competency areas")) + d <- which(vapply(pages, has_line, logical(1), "Appendix D: Updates")) + # The table of contents names every appendix too. The body heading is the + # last occurrence. + list(cards = max(a):(max(b) - 1), statements = (max(b) + 1):(max(c) - 1), + competency = max(c):(max(d) - 1)) +} + +main <- function() { + message("=== Saudi Cybersecurity Workforce Framework (SCyWF) Ingestion ===") + + manifest_path <- file.path(scywf_config$staging_dir, scywf_config$manifest_filename) + if (!file.exists(manifest_path)) stop("provenance.yml not found at ", manifest_path) + manifest <- read_yaml(manifest_path) + + message("Verifying staged files against provenance.yml hashes...") + verify_scywf_hashes(manifest, scywf_config$staging_dir) + + pdf_path <- file.path(scywf_config$staging_dir, scywf_config$pdf_filename) + pages <- scywf_read_pages(pdf_path) + message(" Pages: ", length(pages)) + where <- scywf_locate(pages) + + cards <- scywf_parse_cards(pages[where$cards]) + cards$roles$page <- where$cards[cards$roles$page] + + # Every Appendix B page repeats its column header ("Task ID", "ID" with + # "Knowledge" above it, "Skill ID"). Each table starts on a fresh page, so a + # page holds one table, and its type is read from its codes' letter. + stmt_parts <- list() + for (p in where$statements) { + w <- pages[[p]] + header <- w |> filter(text == "ID", x < 130) + if (nrow(header) != 1) stop("Page ", p, ": expected one ID column header.") + body <- w |> filter(y > header$y + 3) + letters_on_page <- unique(substr(body$text[str_detect(body$text, scywf_code_regex) & + body$x < 110], 1, 1)) + if (length(letters_on_page) == 0) next + if (length(letters_on_page) != 1) stop("Page ", p, ": codes of more than one table.") + kind <- c(T = "task", K = "knowledge", S = "skill")[[letters_on_page]] + parsed <- scywf_parse_statement_page(body, kind) + parsed$rows$page <- rep(p, nrow(parsed$rows)) + stmt_parts[[length(stmt_parts) + 1]] <- parsed + } + statements <- map(stmt_parts, "rows") |> bind_rows() + stmt_joins <- map(stmt_parts, "joins") |> bind_rows() |> mutate(field = "statement_text") + + ca_parts <- map(where$competency, \(p) { + parsed <- scywf_parse_competency_page(pages[[p]]) + parsed$rows$page <- p + parsed + }) + competency <- map(ca_parts, "rows") |> bind_rows() + ca_joins <- map(ca_parts, "joins") |> bind_rows() + + groups <- scywf_groups(cards$roles) + roles <- groups$roles |> + select(role_id, role_name, category_id, specialty_area_id, category_text, + specialty_area_text, description, competency_areas_text, + competency_areas_note, page) + role_statements <- cards$codes + role_ca <- scywf_role_competency_areas(roles) + + unresolved <- role_statements |> filter(!statement_id %in% statements$statement_id) + unresolved_ca <- role_ca |> filter(!ca_id %in% competency$ca_id) + + joins <- bind_rows( + tibble(id = character(), field = character(), left = character(), right = character()), + cards$joins, stmt_joins, ca_joins + ) |> + select(id, field, left, right) + + reference <- read_file(file.path(scywf_config$staging_dir, scywf_config$check_filename)) + check <- scywf_verbatim_check(roles, statements, role_statements, competency, + groups$categories, groups$specialty_areas, reference) + overlap <- scywf_nice_overlap(statements, scywf_config$nice_tables_dir) + + # Checks that stop the ingest. + problems <- c( + if (nrow(roles) != 40) paste("job roles:", nrow(roles), "vs the document's stated 40"), + if (nrow(groups$categories) != 5) paste("categories:", nrow(groups$categories), "vs 5"), + if (nrow(groups$specialty_areas) != 12) + paste("specialty areas:", nrow(groups$specialty_areas), "vs 12"), + if (anyDuplicated(roles$role_id)) "duplicate job role IDs", + if (anyDuplicated(statements$statement_id)) "duplicate statement codes in Appendix B", + if (anyDuplicated(competency$ca_id)) "duplicate competency area codes", + if (anyDuplicated(role_statements[c("role_id", "statement_id")])) + "a card prints the same code twice", + if (any(substr(statements$statement_id, 1, 1) != + c(task = "T", knowledge = "K", skill = "S")[statements$statement_type])) + "a statement code sits in the wrong table", + if (nrow(unresolved_ca) > 0) "a card cites a CA code missing from Appendix C", + if (!all(check$found)) + paste(sum(!check$found), "carried string(s) not found in the reference text") + ) + + tables_dir <- file.path(scywf_config$staging_dir, scywf_config$tables_subdir) + dir.create(tables_dir, showWarnings = FALSE, recursive = TRUE) + write_csv(groups$categories, file.path(tables_dir, "categories.csv")) + write_csv(groups$specialty_areas, file.path(tables_dir, "specialty-areas.csv")) + write_csv(roles, file.path(tables_dir, "roles.csv"), na = "") + write_csv(statements, file.path(tables_dir, "statements.csv"), na = "") + write_csv(role_statements, file.path(tables_dir, "role-statements.csv")) + write_csv(competency, file.path(tables_dir, "competency-areas.csv")) + write_csv(role_ca, file.path(tables_dir, "role-competency-areas.csv")) + write_csv(unresolved, file.path(tables_dir, "unresolved-codes.csv")) + write_csv(joins, file.path(tables_dir, "hyphen-joins.csv")) + write_csv(check, file.path(tables_dir, "verbatim-check.csv")) + if (!is.null(overlap)) { + write_csv(overlap, file.path(tables_dir, "nice-overlap.csv")) + write_csv(attr(overlap, "same_text_any_code"), + file.path(tables_dir, "nice-same-text.csv")) + } + + if (length(problems) > 0) { + stop("SCyWF extraction failed its checks:\n ", paste(problems, collapse = "\n ")) + } + + # The staged manifest is the provenance anchor and is kept as written. Only + # the counts this script measures are recorded under `extraction`. + manifest$extraction$categories <- nrow(groups$categories) + manifest$extraction$specialty_areas <- nrow(groups$specialty_areas) + manifest$extraction$job_roles <- nrow(roles) + manifest$extraction$tasks <- sum(statements$statement_type == "task") + manifest$extraction$knowledge <- sum(statements$statement_type == "knowledge") + manifest$extraction$skills <- sum(statements$statement_type == "skill") + manifest$extraction$statements <- nrow(statements) + manifest$extraction$role_statement_links <- nrow(role_statements) + manifest$extraction$unresolved_card_codes <- nrow(unresolved) + manifest$extraction$competency_areas <- nrow(competency) + manifest$extraction$role_competency_area_links <- nrow(role_ca) + manifest$extraction$hyphenation_joins <- nrow(joins) + manifest$extraction$verbatim_checked <- nrow(check) + manifest$extraction$verbatim_found <- sum(check$found) + if (!is.null(overlap)) { + manifest$extraction$nice_shared_codes <- nrow(overlap) + manifest$extraction$nice_identical_text <- sum(overlap$identical_text) + manifest$extraction$nice_same_text_any_code <- + n_distinct(attr(overlap, "same_text_any_code")$statement_id) + } + write_yaml(manifest, manifest_path) + + message("\n=== Summary ===") + message(" Categories: ", nrow(groups$categories), + " Specialty areas: ", nrow(groups$specialty_areas), + " Job roles: ", nrow(roles)) + message(" Statements: ", nrow(statements), " (", + sum(statements$statement_type == "task"), " tasks, ", + sum(statements$statement_type == "knowledge"), " knowledge, ", + sum(statements$statement_type == "skill"), " skills)") + message(" Role-statement links: ", nrow(role_statements), + " unresolved card codes: ", nrow(unresolved)) + message(" Competency areas: ", nrow(competency), + " role-CA links: ", nrow(role_ca)) + message(" Verbatim check: ", sum(check$found), " of ", nrow(check), " found") + if (!is.null(overlap)) { + message(" Codes shared with NICE: ", nrow(overlap), + ", identical text: ", sum(overlap$identical_text), + ", SCyWF statements worded as some NICE statement: ", + n_distinct(attr(overlap, "same_text_any_code")$statement_id)) + } + message("\nDone.") + + invisible(list(roles = roles, statements = statements, role_statements = role_statements, + competency = competency, check = check, overlap = overlap)) +} + +if (sys.nframe() == 0) { + main() +} diff --git a/scripts/015-verify-ingestion.R b/scripts/015-verify-ingestion.R index 20ec5d0..81f7312 100644 --- a/scripts/015-verify-ingestion.R +++ b/scripts/015-verify-ingestion.R @@ -125,6 +125,30 @@ verify_provenance <- function(framework, results) { } } + # A manifest may instead list several staged files under source$files, each + # with its own filename and file_sha256 (csta-2026 stages its JSON, the PDF + # and CSTA's crosswalk that way). Every listed file must be present and + # match. + for (f in manifest$source$files %||% list()) { + if (is.null(f$filename) || is.null(f$file_sha256)) next + source_path <- file.path(fw_dir, f$filename) + check_name <- glue("provenance.sha256.{f$filename}") + if (!file.exists(source_path)) { + results <- record_check(results, framework, check_name, "hard", + glue("source file declared but not found: {source_path}")) + next + } + actual_sha <- digest(file = source_path, algo = "sha256") + if (identical(actual_sha, f$file_sha256)) { + results <- record_check(results, framework, check_name, "pass", + "SHA256 matches declared") + } else { + results <- record_check(results, framework, check_name, "hard", + "SHA256 mismatch", + list(declared = f$file_sha256, actual = actual_sha)) + } + } + results } @@ -245,6 +269,33 @@ framework_actual_counts <- function(framework, tables_dir) { concepts = if (!is.null(standards)) length(unique(standards$concept)) else NULL ) }, + `csta-2026` = { + standards <- safe_read(file.path(tables_dir, "standards.csv")) + units <- safe_read(file.path(tables_dir, "units.csv")) + boundaries <- safe_read(file.path(tables_dir, "boundaries.csv")) + examples <- safe_read(file.path(tables_dir, "examples.csv")) + found <- if (is.null(standards)) NULL else standards[standards$tier == "foundational", ] + list( + standards_count = nrow_or_null(standards), + foundational_standards = nrow_or_null(found), + specialty_standards = if (is.null(standards)) NULL else sum(standards$tier == "specialty"), + levels_count = if (is.null(standards)) NULL else dplyr::n_distinct(standards$level), + foundational_concepts = if (is.null(found)) NULL else dplyr::n_distinct(found$concept), + specialty_areas = if (is.null(standards)) NULL + else dplyr::n_distinct(standards$specialty_area_code, na.rm = TRUE), + cybersecurity_standards = if (is.null(standards)) NULL + else sum(standards$specialty_area_code %in% "CYB"), + # Units are the observed (level, concept) pairs, so an area published + # at one tier only (X+CS) contributes one unit, not two. + organizing_units = nrow_or_null(units), + foundational_units = if (is.null(units)) NULL else sum(units$tier == "foundational"), + specialty_units = if (is.null(units)) NULL else sum(units$tier == "specialty"), + boundary_statements = nrow_or_null(boundaries), + implementation_examples = nrow_or_null(examples), + standards_with_examples = if (is.null(examples)) NULL else dplyr::n_distinct(examples$code), + ai_standards = if (is.null(standards)) NULL else sum(standards$ai_standard) + ) + }, csec2017 = { kas <- safe_read(file.path(tables_dir, "knowledge-areas.csv")) essentials <- safe_read(file.path(tables_dir, "essentials.csv")) @@ -393,14 +444,60 @@ framework_actual_counts <- function(framework, tables_dir) { } ) }, + scywf = { + stmts <- safe_read(file.path(tables_dir, "statements.csv")) + links <- safe_read(file.path(tables_dir, "role-statements.csv")) + unres <- safe_read(file.path(tables_dir, "unresolved-codes.csv")) + verbatim <- safe_read(file.path(tables_dir, "verbatim-check.csv")) + list( + categories = safe_read(file.path(tables_dir, "categories.csv")) |> nrow_or_null(), + specialty_areas = safe_read(file.path(tables_dir, "specialty-areas.csv")) |> nrow_or_null(), + job_roles = safe_read(file.path(tables_dir, "roles.csv")) |> nrow_or_null(), + tasks = if (is.null(stmts)) NULL else sum(stmts$statement_type == "task"), + knowledge = if (is.null(stmts)) NULL else sum(stmts$statement_type == "knowledge"), + skills = if (is.null(stmts)) NULL else sum(stmts$statement_type == "skill"), + statements = nrow_or_null(stmts), + role_statement_links = nrow_or_null(links), + unresolved_card_codes = nrow_or_null(unres), + # Appendix B statements printed on no role card. + orphan_statements = if (is.null(stmts) || is.null(links)) NULL + else sum(!stmts$statement_id %in% links$statement_id), + competency_areas = safe_read(file.path(tables_dir, "competency-areas.csv")) |> nrow_or_null(), + role_competency_area_links = safe_read(file.path(tables_dir, "role-competency-areas.csv")) |> nrow_or_null(), + verbatim_failures = if (is.null(verbatim)) NULL else sum(!verbatim$found) + ) + }, + cybok = { + topics <- safe_read(file.path(tables_dir, "topics.csv")) + im <- safe_read(file.path(tables_dir, "indicative-material.csv")) + atoz <- safe_read(file.path(tables_dir, "a-to-z-resolution.csv")) + verbatim <- safe_read(file.path(tables_dir, "verbatim-check.csv")) + xw <- safe_read(file.path(tables_dir, "crosswalk-ka-resolution.csv")) + list( + categories = safe_read(file.path(tables_dir, "categories.csv")) |> nrow_or_null(), + knowledge_areas = safe_read(file.path(tables_dir, "knowledge-areas.csv")) |> nrow_or_null(), + topics = nrow_or_null(topics), + indicative_material = nrow_or_null(im), + topics_without_indicative_material = + if (is.null(topics) || is.null(im)) NULL else sum(!topics$topic_id %in% im$topic_id), + a_to_z_rows = nrow_or_null(atoz), + a_to_z_rows_resolved = if (is.null(atoz)) NULL else sum(atoz$status == "resolved" & atoz$ka_acronym != "CI"), + verbatim_failures = if (is.null(verbatim)) NULL else sum(!verbatim$found), + crosswalk_ka_names_unresolved = if (is.null(xw)) NULL else sum(xw$resolution == "unresolved") + ) + }, digcomp = { areas <- safe_read(file.path(tables_dir, "competence-areas.csv")) competences <- safe_read(file.path(tables_dir, "competences.csv")) descs <- safe_read(file.path(tables_dir, "competence-descriptions.csv")) + statements <- safe_read(file.path(tables_dir, "competence-statements.csv")) + outcomes <- safe_read(file.path(tables_dir, "learning-outcomes.csv")) list( - competence_areas = nrow_or_null(areas), - competences = nrow_or_null(competences), - descriptions_found = if (!is.null(descs)) sum(!is.na(descs$description)) else NULL + competence_areas = nrow_or_null(areas), + competences = nrow_or_null(competences), + descriptions_found = if (!is.null(descs)) sum(!is.na(descs$description)) else NULL, + competence_statements = nrow_or_null(statements), + learning_outcomes = nrow_or_null(outcomes) ) }, list() @@ -514,11 +611,19 @@ text_fields_by_framework <- function(framework) { # clarification is optional per CSTA doc structure; not checked for non-empty list(label = "standard-text", file = "standards.csv", column = "standard") ), + `csta-2026` = list( + list(label = "standard-text", file = "standards.csv", column = "title"), + list(label = "boundary-statement", file = "boundaries.csv", column = "text"), + list(label = "example-text", file = "examples.csv", column = "text") + ), csec2017 = list( list(label = "essential-text", file = "essentials.csv", column = "element_text") ), digcomp = list( - list(label = "competence-name", file = "competences.csv", column = "competence_name") + list(label = "competence-name", file = "competences.csv", column = "competence_name"), + list(label = "area-description", file = "competence-area-descriptions.csv", column = "description"), + list(label = "statement-text", file = "competence-statements.csv", column = "description"), + list(label = "outcome-text", file = "learning-outcomes.csv", column = "description") ), cyqual = list( list(label = "task-text", file = "tasks.csv", column = "description"), @@ -532,6 +637,16 @@ text_fields_by_framework <- function(framework) { list(label = "adjacent-resp", file = "adjacent-roles.csv", column = "responsibility"), list(label = "adjacent-comp", file = "adjacent-role-competencies-long.csv", column = "competency") ), + scywf = list( + list(label = "role-desc", file = "roles.csv", column = "description"), + list(label = "statement-text", file = "statements.csv", column = "text"), + list(label = "ca-desc", file = "competency-areas.csv", column = "description") + ), + cybok = list( + list(label = "ka-name", file = "knowledge-areas.csv", column = "ka_name"), + list(label = "topic-title", file = "topics.csv", column = "title"), + list(label = "indicative-material", file = "indicative-material.csv", column = "term") + ), otccf = list( list(label = "role-desc", file = "job-roles.csv", column = "role_description"), list(label = "role-element", file = "role-elements-long.csv", column = "element_text"), @@ -568,13 +683,19 @@ verify_id_uniqueness <- function(framework, results) { csta = list( list(file = "standards.csv", id_col = "identifier", label = "csta-identifier") ), + `csta-2026` = list( + list(file = "standards.csv", id_col = "code", label = "csta2026-code"), + list(file = "units.csv", id_col = "unit_id", label = "csta2026-unit-id") + ), csec2017 = list( list(file = "knowledge-areas.csv", id_col = "ka_id", label = "ka-id"), list(file = "essentials.csv", id_col = "element_id", label = "essential-id") ), digcomp = list( - list(file = "competence-areas.csv", id_col = "area_id", label = "area-id"), - list(file = "competences.csv", id_col = "competence_id", label = "competence-id") + list(file = "competence-areas.csv", id_col = "area_id", label = "area-id"), + list(file = "competences.csv", id_col = "competence_id", label = "competence-id"), + list(file = "competence-statements.csv", id_col = "statement_id", label = "statement-id"), + list(file = "learning-outcomes.csv", id_col = "outcome_id", label = "outcome-id") ), cyqual = list( list(file = "work-roles.csv", id_col = "code", label = "cyqual-work-role-code"), @@ -589,6 +710,18 @@ verify_id_uniqueness <- function(framework, results) { list(file = "roles.csv", id_col = "role_id", label = "ccssf-role-id"), list(file = "adjacent-roles.csv", id_col = "adjacent_role_id", label = "ccssf-adjacent-role-id") ), + scywf = list( + list(file = "roles.csv", id_col = "role_id", label = "scywf-role-id"), + list(file = "statements.csv", id_col = "statement_id", label = "scywf-statement-code"), + list(file = "competency-areas.csv", id_col = "ca_id", label = "scywf-ca-code"), + list(file = "categories.csv", id_col = "category_id", label = "scywf-category-id"), + list(file = "specialty-areas.csv", id_col = "specialty_area_id", label = "scywf-specialty-area-id") + ), + cybok = list( + list(file = "knowledge-areas.csv", id_col = "ka_acronym", label = "cybok-ka-acronym"), + list(file = "knowledge-areas.csv", id_col = "ka_name", label = "cybok-ka-name"), + list(file = "topics.csv", id_col = "topic_id", label = "cybok-topic-id") + ), otccf = list( list(file = "job-roles.csv", id_col = "role_slug", label = "otccf-role-slug"), list(file = "tscs.csv", id_col = "tsc_slug", label = "otccf-tsc-slug"), @@ -630,6 +763,50 @@ verify_id_uniqueness <- function(framework, results) { results } +# --------------------------------------------------------------------------- +# Invariant 6: verbatim carriage (frameworks licensed on that condition) +# --------------------------------------------------------------------------- + +#' SCyWF is carried under a permission that requires verbatim text, and +#' CyBOK is read from PDFs with no structured release. Both ingests look every +#' carried string up in an independent extraction of the PDF and write the +#' result to verbatim-check.csv. A missing string, a missing check file, or +#' (SCyWF only) a role-card code with no Appendix B statement is a HARD +#' failure here, not a soft count drift. +verify_verbatim_carriage <- function(framework_slug, results) { + if (!framework_slug %in% c("scywf", "cybok")) return(results) + tables_dir <- file.path(verify_config$raw_dir, framework_slug, "tables") + + check_path <- file.path(tables_dir, "verbatim-check.csv") + if (!file.exists(check_path)) { + return(record_check(results, framework_slug, "verbatim.check", "hard", + "verbatim-check.csv missing: the ingest did not run its check")) + } + check <- read_csv(check_path, show_col_types = FALSE, + col_types = cols(.default = col_character(), found = col_logical())) + failed <- check[!check$found, , drop = FALSE] + if (nrow(check) == 0 || nrow(failed) > 0) { + results <- record_check(results, framework_slug, "verbatim.check", "hard", + glue("{nrow(failed)} of {nrow(check)} carried strings not found"), + list(examples = head(paste(failed$field, failed$id), 5))) + } else { + results <- record_check(results, framework_slug, "verbatim.check", "pass", + glue("all {nrow(check)} carried strings found verbatim")) + } + if (!identical(framework_slug, "scywf")) return(results) + + unresolved_path <- file.path(tables_dir, "unresolved-codes.csv") + unresolved <- if (file.exists(unresolved_path)) read_csv(unresolved_path, show_col_types = FALSE) else NULL + if (is.null(unresolved) || nrow(unresolved) > 0) { + results <- record_check(results, framework_slug, "verbatim.card-codes", "hard", + "role-card codes without an Appendix B statement, or no record of the check") + } else { + results <- record_check(results, framework_slug, "verbatim.card-codes", "pass", + "every role-card code resolves to an Appendix B statement") + } + results +} + # --------------------------------------------------------------------------- # Audit trail # --------------------------------------------------------------------------- @@ -702,9 +879,9 @@ main <- function() { # The invariants file is the declaration of what counts as a pipeline # framework, so derive the check list from it rather than scanning # data/raw/ for directories. data/raw/ also accumulates STAGING dirs -- - # crosswalk sources with no assemble_*() adapter (cybok), candidate + # crosswalk sources with no assemble_*() adapter, candidate # frameworks held pending a licensing answer (asd, ukcsc), and data - # acquired ahead of a re-ingest step (csta-2026). Those legitimately + # acquired ahead of an ingest step. Those legitimately # have no declared invariants; scanning the directory treated each one # as an undeclared framework and hard-failed the build, which blocked # the pipeline on data that was never part of it. Declaring a framework @@ -732,6 +909,7 @@ main <- function() { results <- verify_counts(fw, invariants, results) results <- verify_text_integrity(fw, results) results <- verify_id_uniqueness(fw, results) + results <- verify_verbatim_carriage(fw, results) } results_tbl <- print_results(results) diff --git a/scripts/016-summarize-ingestion.R b/scripts/016-summarize-ingestion.R index 4cb57f1..09c35e9 100644 --- a/scripts/016-summarize-ingestion.R +++ b/scripts/016-summarize-ingestion.R @@ -181,6 +181,26 @@ render_summary_doc <- function(manifests, summary_tbl) { lines <- c(lines, "") } + # Errata (any framework that stages an errata.csv and records a + # manifest $errata block, e.g. digcomp, nice) + errata <- m$errata %||% list() + if (length(errata) > 0) { + lines <- c(lines, "### Errata") + status_val <- safe_get(errata, "status") + if (!is.na(status_val)) lines <- c(lines, glue("- **status:** {status_val}")) + applied_val <- errata$applied + if (!is.null(applied_val)) { + lines <- c(lines, glue("- **applied:** {paste(applied_val, collapse=', ')}")) + } + unapplied_val <- errata$unapplied + if (!is.null(unapplied_val)) { + lines <- c(lines, glue("- **unapplied:** {paste(unapplied_val, collapse=', ')}")) + } + note_val <- safe_get(errata, "note") + if (!is.na(note_val)) lines <- c(lines, glue("- **note:** {note_val}")) + lines <- c(lines, "") + } + # Licensing lic <- m$licensing %||% list() if (length(lic) > 0) { @@ -225,6 +245,9 @@ render_summary_doc <- function(manifests, summary_tbl) { # Role = level x concept clusters (5 levels x 5 concepts = 25) 5 * 5 }, + # Observed level x concept and tier x specialty area groups, measured + # by scripts/010-ingest-csta2026.R. + `csta-2026` = extraction$organizing_units, csec2017 = extraction$knowledge_areas, digcomp = extraction$competence_areas, # Work roles only. CyQUAL's 59 competencies are a second organizing @@ -237,6 +260,12 @@ render_summary_doc <- function(manifests, summary_tbl) { # further organizing axes, not roles, so they are not counted here. # The graph carries all 61 units; this column tracks roles. otccf = extraction$job_roles, + # Job roles only. SCyWF's competency areas, specialty areas and + # categories are further organizing units, not roles. The graph + # carries all 81 units. This column tracks roles. + scywf = extraction$job_roles, + # Knowledge Areas, as for CSEC2017. CyBOK asserts no roles. + cybok = extraction$knowledge_areas, NA ) elem_count <- switch(slug, @@ -246,6 +275,7 @@ render_summary_doc <- function(manifests, summary_tbl) { ecsf = extraction$element_count, `cyberorg-k12` = extraction$standards_total, csta = extraction$standards_count, + `csta-2026` = extraction$standards_count, csec2017 = extraction$essentials_total, digcomp = extraction$competences, # Tasks and requirements together are CyQUAL's element population. @@ -265,6 +295,11 @@ render_summary_doc <- function(manifests, summary_tbl) { otccf = extraction$role_element_breakdown$key_task + extraction$tsc_level_statements + extraction$tsc_range_of_application_rows, + # Appendix B's Task, Knowledge and Skill statements. + scywf = extraction$statements, + # Topics and the Indicative Material under them. In the graph the + # Indicative Material is cybed:Subpoint rather than top-level elements. + cybok = extraction$topics + extraction$indicative_material, NA ) diff --git a/scripts/020-assemble-jsonld.R b/scripts/020-assemble-jsonld.R index 92dc887..b3e6220 100644 --- a/scripts/020-assemble-jsonld.R +++ b/scripts/020-assemble-jsonld.R @@ -7,23 +7,27 @@ # Per-framework adapters translate each framework's native structure into # the cybed:OrganizingUnit / cybed:RoleElement abstractions. Workforce # frameworks where the unit is genuinely a work role or work profile -# (NICE, DCWF, CyQUAL and CCSSF work roles, ENISA ECSF profiles, OTCCF job -# roles) additionally assert cybed:Role via build_role_node(). Frameworks +# (NICE, DCWF, CyQUAL and CCSSF work roles, ENISA ECSF profiles, OTCCF and +# SCyWF job roles) additionally assert cybed:Role via build_role_node(). Frameworks # that relate one unit to another (OTCCF's roles to the skills they # require) also return cybed:UnitRelation nodes. Non-workforce frameworks -# (SFIA enumerates skills; Cyber.org K-12, CSTA, CSEC2017, DigComp 2.2 -# enumerate other organizing units) call build_organizing_unit_node() +# (SFIA enumerates skills; Cyber.org K-12, CSTA, CSEC2017, DigComp 3.0 and +# CyBOK enumerate other organizing units) call build_organizing_unit_node() # directly with is_role = FALSE. # # Per-framework subtype mapping: # nice:WorkRole, dcwf:WorkRole, ecsf:RoleProfile, # cyqual:WorkRole, ccssf:WorkRole, ccssf:AdjacentRole, -# otccf:JobRole -> subClassOf cybed:Role -# sfia:Skill, csec:KnowledgeArea, digcomp:CompetenceArea, +# otccf:JobRole, scywf:JobRole -> subClassOf cybed:Role +# sfia:Skill, csec:KnowledgeArea, +# digcomp:CompetenceArea, digcomp:Competence, # cyberorg:StandardGroup, csta:StandardGroup, +# csta2026:StandardGroup, # nice:CompetencyArea, cyqual:Competency, # otccf:TechnicalSkillCompetency, -# otccf:CriticalCoreSkill -> subClassOf cybed:OrganizingUnit +# otccf:CriticalCoreSkill, +# scywf:CompetencyArea, scywf:SpecialtyArea, +# scywf:Category, cybok:KnowledgeArea -> subClassOf cybed:OrganizingUnit # # Output: data/processed/jsonld/.jsonld per framework + a # combined multi-framework graph at data/processed/jsonld/_combined.jsonld. @@ -50,6 +54,14 @@ if (requireNamespace("pkgload", quietly = TRUE) && file.exists(here("DESCRIPTION library(cybedtools) } +# The csta-2026 adapter lives in its own file so the test suite can drive it +# with a synthetic fixture. +source(here("scripts", "_assemble-csta2026.R"), local = TRUE) +# The SCyWF adapter likewise, for the same reason. +source(here("scripts", "_assemble-scywf.R"), local = TRUE) +# And the CyBOK adapter. +source(here("scripts", "_assemble-cybok.R"), local = TRUE) + assembly_config <- list( raw_dir = here("data", "raw"), output_dir = here("data", "processed", "jsonld"), @@ -756,6 +768,20 @@ assemble_csta <- function() { prefix = "csta") } +# The 2026 CSTA PK-12 standards: a separate framework from csta-2017, with +# its own prefix and identifier scheme. The mapping is documented in +# scripts/_assemble-csta2026.R. +assemble_csta2026 <- function() { + build_csta2026_parts( + standards = read_framework_table("csta-2026", "standards"), + boundaries = read_framework_table("csta-2026", "boundaries"), + examples = read_framework_table("csta-2026", "examples"), + units = read_framework_table("csta-2026", "units"), + prov = load_framework_provenance("csta-2026"), + unit_iri_prefix = unit_iri_prefix_for("csta-2026") + ) +} + assemble_csec2017 <- function() { prov <- load_framework_provenance("csec2017") kas <- read_framework_table("csec2017", "knowledge-areas") @@ -820,14 +846,50 @@ assemble_csec2017 <- function() { prefix = "csec") } +#' Build the digcomp-3.0 framework, unit and element nodes. +#' +#' DigComp 3.0 replaces DigComp 2.2 in place (owner decision D1, +#' 2026-08-21). The versionless IRIs `digcomp:AREA-*` and `digcomp:COMP-x.y` +#' survive unchanged from 2.2 (see +#' data/raw/digcomp/v3.0/MAPPING-2.2-to-3.0.md), but their node types +#' change: 2.2 minted competences as `cybed:RoleElement`, with only areas as +#' `cybed:OrganizingUnit`. 3.0 moves elements down to the 362 Competence +#' Statements, so competences become a second organizing-unit tier +#' (`digcomp:Competence`) alongside the 5 areas (`digcomp:CompetenceArea`) -- +#' 26 organizing units total, both tiers flat (mirroring +#' `assemble_nice()`'s two independent WorkRole/CompetencyArea axes: a +#' Competence's `cybed:hasElement` lists its own statements, and an Area's +#' lists every statement across its member competences). +#' +#' Learning Outcomes have no source-provided link to an individual +#' Competence Statement (both are keyed only by competence + proficiency +#' level, many-to-many), so they attach as `cybed:Example` via +#' `cybed:hasExample` on the Competence unit, never on a Statement. +#' +#' Sub-point parser disabled: Competence Statement text yields fragments +#' like "clickbait", "validating", and "AI systems" rather than +#' framework-as-specified enumerations, the same fidelity problem OTCCF and +#' csta-2026 have. Every Competence Statement is emitted as a parent +#' `cybed:RoleElement`, with no `cybed:Subpoint` children. +#' +#' Proficiency levels and the glossary are staged (see +#' scripts/010-ingest-digcomp.R) but not emitted here: neither is a +#' competence-scoped statement or citizen-facing example, and no existing +#' cybed: predicate models an 8/4/6-level crosswalk table or a term +#' glossary. Deferred rather than force-fit. assemble_digcomp <- function() { prov <- load_framework_provenance("digcomp") areas <- read_framework_table("digcomp", "competence-areas") + area_descs <- read_framework_table("digcomp", "competence-area-descriptions") competences <- read_framework_table("digcomp", "competences") - descs <- read_framework_table("digcomp", "competence-descriptions") + comp_descs <- read_framework_table("digcomp", "competence-descriptions") + statements <- read_framework_table("digcomp", "competence-statements") + outcomes <- read_framework_table("digcomp", "learning-outcomes") + + framework_id <- "digcomp-3.0" framework_node <- build_framework_node( - framework_id = "digcomp-2.2", + framework_id = framework_id, framework_name = prov$framework_version, framework_prefix = "digcomp", version = prov$framework_version, @@ -839,55 +901,122 @@ assemble_digcomp <- function() { date_published = prov$version_date ) - # Role = Competence Area. Element = Competence. - parent_element_nodes <- descs |> - purrr::pmap(function(element_id, competence_id, competence_name, description, ...) { - text_val <- if (!is.na(description) && description != "") description else competence_name - build_role_element_node( - element_id = element_id, + # Elements = Competence Statements (362), the graph's RoleElement citizens. + parent_element_nodes <- statements |> + purrr::pmap(function(statement_id, competence_id, area_id, proficiency_level, + ai_label, description, ...) { + competence_name <- comp_descs$competence_name[comp_descs$competence_id == competence_id][1] + node <- build_role_element_node( + element_id = statement_id, framework_prefix = "digcomp", - framework_element_type = "Competence", - element_text = text_val, - source_section = paste0("DigComp ", competence_id, " ", competence_name), - framework_id = "digcomp-2.2" + framework_element_type = "CompetenceStatement", + element_text = description, + source_section = paste0("DigComp 3.0 ", competence_id, " ", + competence_name, ", ", proficiency_level), + framework_id = framework_id ) + c(node, list( + `digcomp:proficiencyLevel` = proficiency_level, + `digcomp:aiLabel` = ai_label + )) }) - expanded <- expand_with_subpoints( - element_nodes = parent_element_nodes, - framework_prefix = "digcomp", - framework_id = "digcomp-2.2", - framework_slug = "digcomp" + # Sub-point parser disabled for digcomp, as for OTCCF and csta-2026: its + # Competence Statement text yields fragments like "clickbait", + # "validating", "AI systems" rather than framework-as-specified + # enumerations, so a parser split would be a unit the package invented + # rather than one JRC printed. Shape matches what expand_with_subpoints() + # returns so the rest of the adapter is unchanged. + expanded <- list( + nodes = parent_element_nodes, + subnode_index = tibble::tibble( + parent_id = character(0), + subnode_id = character(0), + ordinal = integer(0), + node_type = character(0) + ) ) - role_nodes <- areas |> + # Examples = Learning Outcomes (522, post-errata), attached to their + # Competence via cybed:hasExample, never cybed:hasElement. Ordinal is the + # source's own trailing outcome number (LOx.y.NN), preserved rather than + # re-derived, so a reader can cross-check against the published id. + outcome_nodes <- outcomes |> + purrr::pmap(function(outcome_id, competence_id, area_id, proficiency_level, + ko_type, ai_label, description, ...) { + ordinal <- as.integer(stringr::str_extract(outcome_id, "[0-9]+$")) + ex <- build_example_node( + parent_element_id = paste0("COMP-", competence_id), + ordinal = ordinal, + text = description, + framework_prefix = "digcomp", + framework_id = framework_id + ) + c(ex, list( + `digcomp:proficiencyLevel` = proficiency_level, + `digcomp:koType` = ko_type, + `digcomp:aiLabel` = ai_label + )) + }) + + outcome_iris_by_competence <- split( + vapply(outcome_nodes, \(ex) as.character(ex[["@id"]]), character(1)), + paste0("digcomp:COMP-", outcomes$competence_id) + ) + + competence_nodes <- comp_descs |> + purrr::pmap(function(element_id, competence_id, area_id, competence_name, description, ...) { + statement_ids <- statements |> + filter(competence_id == !!competence_id) |> + pull(statement_id) + statement_ids <- extend_role_element_ids(statement_ids, expanded$subnode_index) + + node <- build_organizing_unit_node( + unit_id = element_id, + unit_name = competence_name, + framework_prefix = "digcomp", + framework_subtype = "Competence", + is_role = FALSE, + description = description, + element_ids = statement_ids, + framework_id = framework_id + ) + + example_iris <- outcome_iris_by_competence[[paste0("digcomp:", element_id)]] + if (!is.null(example_iris)) { + node[["cybed:hasExample"]] <- purrr::map(unname(example_iris), + \(eid) list(`@id` = eid)) + } + node + }) + + area_nodes <- areas |> purrr::pmap(function(area_id, area_number, area_name, ...) { - child_competence_ids <- competences |> - filter(area_id == !!area_id) |> - mutate(element_id = paste0("COMP-", competence_id)) |> - pull(element_id) + member_competence_ids <- competences$competence_id[competences$area_id == area_id] + area_statement_ids <- statements |> + filter(competence_id %in% member_competence_ids) |> + pull(statement_id) + area_statement_ids <- extend_role_element_ids(area_statement_ids, expanded$subnode_index) - child_competence_ids <- extend_role_element_ids(child_competence_ids, expanded$subnode_index) + area_description <- area_descs$description[area_descs$area_id == area_id][1] - # DigComp 2.2 organizes content by competence area (5 areas: Information - # and data literacy, Communication and collaboration, Digital content - # creation, Safety, Problem solving). DigComp does not specify roles; - # it is a citizen self-assessment instrument. Assert - # cybed:OrganizingUnit only. + # DigComp does not specify roles; it is a citizen self-assessment + # instrument. Assert cybed:OrganizingUnit only (is_role = FALSE). build_organizing_unit_node( unit_id = area_id, unit_name = area_name, framework_prefix = "digcomp", framework_subtype = "CompetenceArea", is_role = FALSE, - description = paste("DigComp 2.2 Area", area_number, "-", area_name), - element_ids = child_competence_ids, - framework_id = "digcomp-2.2" + description = area_description, + element_ids = area_statement_ids, + framework_id = framework_id, + metadata = list(`digcomp:areaNumber` = as.integer(area_number)) ) }) - list(framework = framework_node, roles = role_nodes, elements = expanded$nodes, - prefix = "digcomp") + list(framework = framework_node, roles = c(area_nodes, competence_nodes), + elements = c(expanded$nodes, outcome_nodes), prefix = "digcomp") } assemble_cyqual <- function() { @@ -1136,7 +1265,7 @@ assemble_ccssf <- function() { attribution = paste( "Canadian Centre for Cyber Security, The Canadian Cyber Security Skills", "Framework (ITSM.00.039), 2022 edition. Copyright Government of Canada.", - "Referenced as the Canadian Centre for Cyber Security asked." + "Used with permission of the Canadian Centre for Cyber Security." ) ) @@ -1634,6 +1763,33 @@ assemble_otccf <- function() { prefix = "otccf") } +# The Saudi Cybersecurity Workforce Framework. The mapping, and which edges +# are cybedtools-derived rather than NCA content, is documented in +# scripts/_assemble-scywf.R. +assemble_scywf <- function() { + build_scywf_parts( + categories = read_framework_table("scywf", "categories"), + specialty_areas = read_framework_table("scywf", "specialty-areas"), + roles = read_framework_table("scywf", "roles"), + statements = read_framework_table("scywf", "statements"), + role_statements = read_framework_table("scywf", "role-statements"), + competency_areas = read_framework_table("scywf", "competency-areas"), + role_competency_areas = read_framework_table("scywf", "role-competency-areas"), + prov = load_framework_provenance("scywf") + ) +} + +# The Cyber Security Body of Knowledge. The mapping is documented in +# scripts/_assemble-cybok.R. +assemble_cybok <- function() { + build_cybok_parts( + knowledge_areas = read_framework_table("cybok", "knowledge-areas"), + topics = read_framework_table("cybok", "topics"), + indicative_material = read_framework_table("cybok", "indicative-material"), + prov = load_framework_provenance("cybok") + ) +} + framework_assemblers <- list( nice = assemble_nice, sfia = assemble_sfia, @@ -1641,11 +1797,14 @@ framework_assemblers <- list( ecsf = assemble_ecsf, `cyberorg-k12` = assemble_cyberorg, csta = assemble_csta, + `csta-2026` = assemble_csta2026, csec2017 = assemble_csec2017, digcomp = assemble_digcomp, cyqual = assemble_cyqual, ccssf = assemble_ccssf, - otccf = assemble_otccf + otccf = assemble_otccf, + scywf = assemble_scywf, + cybok = assemble_cybok ) main <- function() { diff --git a/scripts/025-export-ntriples.R b/scripts/025-export-ntriples.R index ce35abb..6ab426a 100644 --- a/scripts/025-export-ntriples.R +++ b/scripts/025-export-ntriples.R @@ -26,8 +26,8 @@ export_config <- list( jsonld_dir = here("data", "processed", "jsonld"), nt_dir = here("data", "processed", "ntriples"), frameworks = c("nice", "sfia", "dcwf", "ecsf", - "cyberorg-k12", "csta", "csec2017", "digcomp", - "cyqual", "ccssf", "otccf") + "cyberorg-k12", "csta", "csta-2026", "csec2017", "digcomp", + "cyqual", "ccssf", "otccf", "scywf", "cybok") ) convert_one <- function(framework_slug) { diff --git a/scripts/030-export-release.R b/scripts/030-export-release.R index ece0345..5fcabf7 100644 --- a/scripts/030-export-release.R +++ b/scripts/030-export-release.R @@ -63,8 +63,8 @@ release_config <- list( nt_dir = here("data", "processed", "ntriples"), release_dir = here("data", "processed", "release"), frameworks = c("nice", "sfia", "dcwf", "ecsf", - "cyberorg-k12", "csta", "csec2017", "digcomp", - "cyqual", "ccssf", "otccf") + "cyberorg-k12", "csta", "csta-2026", "csec2017", "digcomp", + "cyqual", "ccssf", "otccf", "scywf", "cybok") ) rdf_type_iri <- "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" @@ -134,9 +134,9 @@ assert_graph_gate <- function() { # file because an exclusion removed it is accounted for separately, by # report_exclusion_counts(), which reconciles each file's own before and after. # -# The eleven per-framework documents partition the combined graph. The +# The per-framework documents partition the combined graph. The # assembler builds the combined document by concatenating the same node lists -# it wrote per framework, so the union of the eleven files should equal the +# it wrote per framework, so the union of the per-framework files should equal the # combined file exactly. Checking it here is what makes the per-framework files # a safe source for the release: a triple present only in the combined graph # would be a triple no framework file carries, and it would ship nowhere. @@ -261,7 +261,8 @@ apply_exclusions <- function(lines, slug, config, scope) { framework_entry <- function(slug, lines, config, policy, licenses, out_dir) { policy_value <- policy$policy[match(slug, policy$framework_slug)] scope <- release_scope(policy_value) - license_row <- release_license_row(licenses, slug) + license_row <- release_license_row(licenses, slug, + reserved = policy$framework_slug) framework_iri <- nt_subjects_of_type(lines, framework_class_iri) if (length(framework_iri) != 1L) { diff --git a/scripts/040-build-goldens.R b/scripts/040-build-goldens.R new file mode 100644 index 0000000..84e0e41 --- /dev/null +++ b/scripts/040-build-goldens.R @@ -0,0 +1,293 @@ +#!/usr/bin/env Rscript +# 040-build-goldens.R +# +# Builds the hand-authored conformance fixture (inst/conformance/fixture.nt), +# a mock release directory next to it (for cybed_fetch()/load_graph() tests), +# and one golden CSV per public API function, run against the fixture. +# +# The golden format (exact contract, restated in +# inst/conformance/README.md): UTF-8 encoding, LF line endings, one CSV per +# function, fixed column order, rows sorted by the documented sort key, +# numeric similarity scores rounded to 10 decimal places, NA written as an +# empty string (never the literal "NA"). +# +# Run: Rscript scripts/040-build-goldens.R + +suppressPackageStartupMessages({ + library(here) + library(dplyr) + library(readr) + library(rdflib) +}) + +if (requireNamespace("pkgload", quietly = TRUE) && file.exists(here("DESCRIPTION"))) { + pkgload::load_all(here(), quiet = TRUE) +} else { + library(cybedtools) +} + +conformance_dir <- here("inst", "conformance") +goldens_dir <- file.path(conformance_dir, "goldens") +mock_release_dir <- file.path(conformance_dir, "mock-release", "1.0.0") +dir.create(goldens_dir, recursive = TRUE, showWarnings = FALSE) +dir.create(mock_release_dir, recursive = TRUE, showWarnings = FALSE) + +# --------------------------------------------------------------------------- +# 1. Build the fixture graph +# --------------------------------------------------------------------------- +# +# Two workforce frameworks (fixture-wf1, fixture-wf2) with roles, one +# pedagogy framework (fixture-ped1) with a Subpoint and an Example, a +# statement shared by two units (across wf1 and wf2), one unit relation, and +# non-ASCII text (accented French) on the pedagogy unit. Similarity-relevant +# text overlaps deliberately between wf1's and wf2's roles. + +cybed <- function(local) paste0("https://w3id.org/cybed/ontology#", local) +schema <- function(local) paste0("http://schema.org/", local) +RDF_TYPE <- "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" + +build_fixture_graph <- function() { + rdf <- rdflib::rdf() + add <- function(s, p, o) rdflib::rdf_add(rdf, s, p, o) + + fw1 <- cybed("framework/fixture-wf1") + fw2 <- cybed("framework/fixture-wf2") + fw3 <- cybed("framework/fixture-ped1") + + add(fw1, RDF_TYPE, cybed("Framework")) + add(fw1, schema("name"), "Fixture Workforce Framework One") + add(fw1, cybed("jurisdiction"), "US") + add(fw1, cybed("sector"), "civilian") + add(fw1, cybed("specificity"), "cybersecurity-specific") + + add(fw2, RDF_TYPE, cybed("Framework")) + add(fw2, schema("name"), "Fixture Workforce Framework Two") + add(fw2, cybed("jurisdiction"), "EU") + add(fw2, cybed("sector"), "civilian") + add(fw2, cybed("specificity"), "cybersecurity-specific") + + add(fw3, RDF_TYPE, cybed("Framework")) + add(fw3, schema("name"), "Fixture Pedagogy Framework One") + add(fw3, cybed("jurisdiction"), "global") + add(fw3, cybed("sector"), "education") + add(fw3, cybed("specificity"), "cybersecurity-specific") + + # --- wf1 roles --- + role1 <- cybed("role/fixture-role-1") + role2 <- cybed("role/fixture-role-2") + for (r in c(role1, role2)) { + add(r, RDF_TYPE, cybed("Role")) + add(r, RDF_TYPE, cybed("OrganizingUnit")) + add(r, cybed("partOf"), fw1) + } + add(role1, schema("name"), "Security Analyst") + add(role2, schema("name"), "Network Defender") + + # --- wf2 roles --- + role3 <- cybed("role/fixture-role-3") + role4 <- cybed("role/fixture-role-4") + for (r in c(role3, role4)) { + add(r, RDF_TYPE, cybed("Role")) + add(r, RDF_TYPE, cybed("OrganizingUnit")) + add(r, cybed("partOf"), fw2) + } + add(role3, schema("name"), "Incident Handler") + add(role4, schema("name"), "Threat Hunter") + + # --- ped1 unit (grade-band x sub-concept style cell) --- + unit5 <- cybed("unit/fixture-unit-5") + add(unit5, RDF_TYPE, cybed("OrganizingUnit")) + add(unit5, cybed("partOf"), fw3) + add(unit5, schema("name"), "Grade Band Data Protection Cell") + + # --- statement text, similarity-relevant overlap between role1 and role3 --- + el_role1 <- cybed("element/fixture-el-role1") + el_role2 <- cybed("element/fixture-el-role2") + el_role3 <- cybed("element/fixture-el-role3") + el_role4 <- cybed("element/fixture-el-role4") + el_unit5 <- cybed("element/fixture-el-unit5") + + for (e in c(el_role1, el_role2, el_role3, el_role4, el_unit5)) { + add(e, RDF_TYPE, cybed("RoleElement")) + } + add(el_role1, cybed("partOf"), fw1) + add(el_role2, cybed("partOf"), fw1) + add(el_role3, cybed("partOf"), fw2) + add(el_role4, cybed("partOf"), fw2) + add(el_unit5, cybed("partOf"), fw3) + + add(el_role1, cybed("elementText"), + "Monitors network traffic for security incidents and analyzes alerts") + add(el_role2, cybed("elementText"), + "Configures firewalls and defends the network perimeter") + add(el_role3, cybed("elementText"), + "Monitors network traffic for incidents and coordinates incident response") + add(el_role4, cybed("elementText"), + "Hunts for adversaries hiding in network traffic and endpoint logs") + add(el_unit5, cybed("elementText"), + "Protéger les données personnelles et respecter la vie privée") + + add(role1, cybed("hasElement"), el_role1) + add(role2, cybed("hasElement"), el_role2) + add(role3, cybed("hasElement"), el_role3) + add(role4, cybed("hasElement"), el_role4) + add(unit5, cybed("hasElement"), el_unit5) + + # A statement shared by two units, across two frameworks: role2 and role4 + # both link to the same el_role1 in addition to their own text (models a + # cross-framework alignment note without a native equivalence type). + add(role2, cybed("hasElement"), el_role1) + add(role4, cybed("hasElement"), el_role1) + + # --- ped1 Subpoint + Example, non-ASCII carried by the Subpoint --- + sub1 <- cybed("element/fixture-el-unit5.sub.1") + add(sub1, RDF_TYPE, cybed("RoleElement")) + add(sub1, RDF_TYPE, cybed("Subpoint")) + add(sub1, cybed("partOf"), fw3) + add(sub1, cybed("elaborates"), el_unit5) + add(sub1, cybed("elementText"), "chiffrement et contrôle d'accès") + add(unit5, cybed("hasElement"), sub1) + + ex1 <- cybed("element/fixture-el-unit5.example.1") + add(ex1, RDF_TYPE, cybed("RoleElement")) + add(ex1, RDF_TYPE, cybed("Example")) + add(ex1, cybed("partOf"), fw3) + add(ex1, cybed("elementText"), + "Clarification statement: includes GDPR-relevant scenarios") + add(unit5, cybed("hasExample"), ex1) + + # --- one unit relation: role1 requires role3 --- + rel1 <- cybed("relation/fixture-role-1--fixture-role-3--requires") + add(rel1, RDF_TYPE, cybed("UnitRelation")) + add(rel1, cybed("fromUnit"), role1) + add(rel1, cybed("toUnit"), role3) + add(rel1, cybed("relationLabel"), "requires") + add(rel1, cybed("partOf"), fw1) + + rdf +} + +fixture_rdf <- build_fixture_graph() +fixture_path <- file.path(conformance_dir, "fixture.nt") +rdflib::rdf_serialize(fixture_rdf, fixture_path, format = "ntriples") + +# Normalize to sorted, LF, UTF-8 lines for a reproducible fixture file. +fixture_lines <- sort(unique(readLines(fixture_path, warn = FALSE, encoding = "UTF-8"))) +con <- file(fixture_path, open = "wb") +writeLines(fixture_lines, con, sep = "\n", useBytes = TRUE) +close(con) + +message("Fixture written: ", fixture_path, " (", length(fixture_lines), " triples)") + +# --------------------------------------------------------------------------- +# 2. Build the mock release directory (manifest + gzipped per-framework files) +# --------------------------------------------------------------------------- + +mock_frameworks <- c("fixture-wf1", "fixture-wf2", "fixture-ped1") +fixture_rdf_fresh <- rdflib::rdf_parse(fixture_path, format = "ntriples") + +framework_lines_for <- function(slug) { + fw_iri <- cybed(paste0("framework/", slug)) + q <- paste0( + "PREFIX cybed: \n", + sprintf("SELECT ?s ?p ?o WHERE { ?s ?p ?o . }") + ) + # Single-BGP discipline: pull every triple, filter to this framework's + # subjects in R (subjects sharing this framework's partOf, plus the + # framework node itself). + all_triples <- rdflib::rdf_query(fixture_rdf_fresh, q) + partof <- all_triples[all_triples$p == cybed("partOf") & all_triples$o == fw_iri, "s"] + subjects <- unique(c(fw_iri, partof)) + keep <- all_triples[all_triples$s %in% subjects, ] + # Serialize the kept subset via a scratch in-memory graph. + scratch <- rdflib::rdf() + for (i in seq_len(nrow(keep))) { + rdflib::rdf_add(scratch, keep$s[i], keep$p[i], keep$o[i]) + } + tmp <- tempfile(fileext = ".nt") + rdflib::rdf_serialize(scratch, tmp, format = "ntriples") + lines <- sort(unique(readLines(tmp, warn = FALSE, encoding = "UTF-8"))) + unlink(tmp) + lines +} + +manifest_files <- lapply(mock_frameworks, function(slug) { + lines <- framework_lines_for(slug) + payload <- paste(lines, collapse = "\n") + payload_bytes <- c(charToRaw(payload), as.raw(0x0a)) + gz_path <- file.path(mock_release_dir, paste0(slug, ".nt.gz")) + con <- gzfile(gz_path, "wb") + writeBin(payload_bytes, con) + close(con) + compressed_bytes <- readBin(gz_path, "raw", file.size(gz_path)) + list( + slug = slug, + file = paste0(slug, ".nt.gz"), + triples = length(lines), + bytes = length(compressed_bytes), + bytes_uncompressed = length(payload_bytes), + sha256 = digest::digest(compressed_bytes, algo = "sha256", serialize = FALSE), + sha256_uncompressed = digest::digest(payload_bytes, algo = "sha256", serialize = FALSE) + ) +}) + +manifest <- list( + manifest_version = "1", + release_version = "1.0.0", + release_date = "2026-09-21", + files = manifest_files +) +jsonlite::write_json( + manifest, file.path(mock_release_dir, "manifest.json"), + auto_unbox = TRUE, pretty = TRUE +) +message("Mock release written: ", mock_release_dir) + +# --------------------------------------------------------------------------- +# 3. Golden CSV writer, enforcing the exact contract +# --------------------------------------------------------------------------- + +write_golden <- function(df, name, sort_cols) { + df <- df |> + dplyr::mutate(dplyr::across( + dplyr::where(is.numeric) & !dplyr::any_of(c("rank")), + ~ round(.x, 10) + )) |> + dplyr::arrange(dplyr::across(dplyr::all_of(sort_cols))) + + path <- file.path(goldens_dir, paste0(name, ".csv")) + con <- file(path, open = "wb", encoding = "native.enc") + readr::write_csv(df, con, na = "", eol = "\n") + close(con) + message("Golden written: ", path, " (", nrow(df), " rows)") +} + +rdf <- rdflib::rdf_parse(fixture_path, format = "ntriples") + +write_golden(framework_metadata(rdf), "framework_metadata", + c("framework")) +write_golden(unit_element_bindings(rdf), "unit_element_bindings", + c("role", "element")) +write_golden(unit_relation_bindings(rdf), "unit_relation_bindings", + c("from_unit", "to_unit", "relation")) +write_golden( + framework_similarity(rdf, from = "fixture-wf1", to = "fixture-wf2", n = 5), + "framework_similarity", + c("from_unit", "rank") +) +write_golden(cybed_license(), "cybed_license", c("slug")) +write_golden(cybedtools::framework_summary, "framework_summary", + c("framework_slug")) +write_golden(role_framework_bindings(rdf), "role_framework_bindings", + c("role", "framework")) +write_golden(organizing_unit_framework_bindings(rdf), "organizing_unit_framework_bindings", + c("unit", "framework")) +write_golden(element_framework_bindings(rdf), "element_framework_bindings", + c("element", "framework")) +write_golden(example_framework_bindings(rdf), "example_framework_bindings", + c("example", "framework")) +write_golden(subpoint_framework_bindings(rdf), "subpoint_framework_bindings", + c("subpoint", "framework")) +write_golden(element_text(rdf), "element_text", c("element")) + +message("\nDone. ", length(list.files(goldens_dir)), " golden file(s) written.") diff --git a/scripts/README.md b/scripts/README.md index aaa6371..6685db1 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -26,8 +26,11 @@ Different upstream frameworks carry different licensing. Some permit redistribut | SFIA | SFIA Foundation non-commercial free-use | structural metadata only via jankudev/sfia-tools; do not redistribute SFIA skill text | | Cyber.org K-12 | CC BY-NC 4.0 | non-commercial only; do not include in commercial offerings | | CSTA K-12 CS | CC BY-NC-SA 4.0 | non-commercial, share-alike | +| CSTA PK-12 CS (2026) | CC BY-NC-SA 4.0 | non-commercial, share-alike | | CSEC2017 | ACM/IEEE/AIS/IFIP, educational use | safe for educational development; analytical derivatives publishable with attribution | | DigComp | EU open re-use | typically safe; verify specific terms | +| SCyWF | written permission of the National Cybersecurity Authority (NCA) | full content with attribution to NCA, carried verbatim, derived edges marked as derived | +| CyBOK | Open Government Licence v3.0 (Crown Copyright, NCSC) | full content with CyBOK's prescribed attribution | Analytical derivatives (code frequencies, cross-framework mappings, structural comparisons) are generally publishable with attribution to the source framework, subject to the upstream license. @@ -37,7 +40,7 @@ See `docs/framework-data-sources.md` for canonical source URLs and ingestion not ``` 000-build.R # Master orchestrator -010-ingest-.R # 11 scripts, one per framework +010-ingest-.R # 14 scripts, one per framework 015-verify-ingestion.R # Six-invariant verification rig 016-summarize-ingestion.R # Generates docs/ingestion-summary.md 020-assemble-jsonld.R # Builds JSON-LD from tidy CSVs @@ -46,6 +49,9 @@ See `docs/framework-data-sources.md` for canonical source URLs and ingestion not 030-export-release.R # Builds the public per-framework data release 040-run-sparql.R # SPARQL query runner _ingest-common.R # Shared helpers for the ingesters +_assemble-csta2026.R # JSON-LD adapter for csta-2026, sourced by 020 +_assemble-scywf.R # JSON-LD adapter for scywf, sourced by 020 +_assemble-cybok.R # JSON-LD adapter for cybok, sourced by 020 _release-common.R # Pure helpers for the release export utils/jsonld-helpers.R # Mirror of R/jsonld-helpers.R for script use ``` diff --git a/scripts/_assemble-csta2026.R b/scripts/_assemble-csta2026.R new file mode 100644 index 0000000..4260cff --- /dev/null +++ b/scripts/_assemble-csta2026.R @@ -0,0 +1,198 @@ +# JSON-LD adapter for the 2026 CSTA PK-12 Computer Science Standards. +# +# Sourced by scripts/020-assemble-jsonld.R, which reads the staged tables and +# calls build_csta2026_parts(). Kept in its own file so the test suite can +# drive the adapter with a small synthetic fixture instead of the staged data. +# Requires the cybedtools JSON-LD constructors (build_*_node, +# expand_with_subpoints, extend_role_element_ids) to be loaded. +# +# scripts/ is .Rbuildignore'd, so nothing here ships in the package. +# +# Mapping onto the cybed: layer. +# +# Standard (331) cybed:RoleElement, subtype csta2026:Standard. The +# element id is CSTA's code verbatim (MS-ALG-PS-01, +# S1-CYB-ND-01) and the element text is the standard +# as published. +# StandardGroup cybed:OrganizingUnit only, never cybed:Role. The +# foundational tier groups by level x concept (EK, E1 +# to E5, MS, HS x 5 concepts). The specialty tier +# groups by tier level x specialty area (S1, S2 x the +# areas observed at that level). Units are built from +# the observed pairs, so X+CS, which CSTA publishes at +# Specialty I only, gets no empty S2 unit. The unit id +# is CSTA's own code prefix (MS-ALG, S1-CYB). CSTA +# names the level, concept and specialty area but not +# the group itself, so the subtype is the descriptive +# csta2026:StandardGroup, as for csta-2017 and +# Cyber.org K-12. No subconcept-level units: the +# subconcept is recorded on each standard instead. +# Implementation cybed:Example, one per published example, linked +# example from its standard by cybed:hasExample and never by +# cybed:hasElement, the treatment csta-2017 gives its +# clarification column. +# +# Per-standard literals. No existing cybed: predicate carries a curricular +# tier, a specialty area code, a boundary statement or a publisher flag, and +# no other framework in the package mints framework-level predicates yet, so +# these are csta2026: terms: +# +# csta2026:tier "foundational" or "specialty" +# csta2026:specialtyArea specialty area code (AIN, CYB, DSC, GMD, PHY, +# SWD, XCS), specialty standards only +# csta2026:subconcept subconcept, or specialty subarea, as published +# csta2026:boundaryStatement one literal per published boundary paragraph. +# Boundary statements set the limits of what a +# standard asks for. They are part of the +# standard, not teaching examples, so they are +# not cybed:Example nodes. +# csta2026:aiStandard boolean, CSTA's "AI standard" flag +# csta2026:exampleType on each Example: Unplugged, Computer-based or +# Teacher choice, as published +# +# cybed:sourceSection carries level.concept.subconcept, e.g. +# "MS.Algorithms & Design.Algorithmic Problem Solving". +# +# Not modelled yet: practices, dispositions, progressions, specialty to +# foundation progressions and interdisciplinary connections. + +csta2026_framework_id <- "csta-2026" +csta2026_prefix <- "csta2026" + +csta2026_level_labels <- c( + EK = "PK/Kindergarten", E1 = "Grade 1", E2 = "Grade 2", E3 = "Grade 3", + E4 = "Grade 4", E5 = "Grade 5", MS = "Middle School", HS = "High School", + S1 = "Specialty I", S2 = "Specialty II" +) + +#' Build the csta-2026 framework, unit and element nodes. +#' +#' @param standards Tibble, one row per standard: code, level, tier, concept, +#' subconcept, specialty_area_code, title, ai_standard, source_section. +#' @param boundaries Tibble: code, ordinal, text. +#' @param examples Tibble: code, ordinal, example_type, text. +#' @param units Tibble: unit_id, level, tier, concept, concept_code, +#' specialty_area_code. +#' @param prov List, the parsed provenance manifest. +#' @param unit_iri_prefix Optional unit IRI discriminator, passed through. +#' @return List with framework, roles, elements and prefix, the shape the +#' other assemblers return. +build_csta2026_parts <- function(standards, boundaries, examples, units, prov, + unit_iri_prefix = NULL) { + framework_node <- build_framework_node( + framework_id = csta2026_framework_id, + framework_name = prov$framework_version, + framework_prefix = csta2026_prefix, + version = prov$framework_version, + publisher = prov$source$publisher, + jurisdiction = "US", + sector = "K-12-education", + specificity = "general-computing", + license = prov$licensing$source_license, + date_published = prov$version_date, + attribution = paste0(prov$suggested_citation, " DOI: https://doi.org/", + prov$doi) + ) + + boundary_lookup <- split(boundaries$text[order(boundaries$code, boundaries$ordinal)], + boundaries$code[order(boundaries$code, boundaries$ordinal)]) + + parent_element_nodes <- standards |> + purrr::pmap(function(code, tier, subconcept, specialty_area_code, title, + ai_standard, source_section, ...) { + node <- build_role_element_node( + element_id = code, + framework_prefix = csta2026_prefix, + framework_element_type = "Standard", + element_text = title, + source_section = source_section, + framework_id = csta2026_framework_id + ) + extra <- list(`csta2026:tier` = tier) + if (!is.na(specialty_area_code) && nzchar(specialty_area_code)) { + extra[["csta2026:specialtyArea"]] <- specialty_area_code + } + extra[["csta2026:subconcept"]] <- subconcept + bounds <- boundary_lookup[[code]] + if (length(bounds) > 0) { + extra[["csta2026:boundaryStatement"]] <- unname(bounds) + } + extra[["csta2026:aiStandard"]] <- isTRUE(as.logical(ai_standard)) + c(node, extra) + }) + + # Sub-point parser disabled for csta-2026, as for OTCCF: CSTA's standards + # are published statements, and a parser-split fragment of a standard + # would be a unit the package invented rather than one CSTA printed. + # Shape matches what expand_with_subpoints() returns so the rest of the + # adapter is unchanged. Implementation examples are still attached below, + # from CSTA's own example list rather than parsed from the standard text. + expanded <- list( + nodes = parent_element_nodes, + subnode_index = tibble::tibble( + parent_id = character(0), + subnode_id = character(0), + ordinal = integer(0), + node_type = character(0) + ) + ) + + example_nodes <- examples |> + purrr::pmap(function(code, ordinal, example_type, text, ...) { + ex <- build_example_node( + parent_element_id = code, + ordinal = as.integer(ordinal), + text = text, + framework_prefix = csta2026_prefix, + framework_id = csta2026_framework_id + ) + if (!is.na(example_type) && nzchar(example_type)) { + ex[["csta2026:exampleType"]] <- example_type + } + ex + }) + + example_iris <- split( + vapply(example_nodes, \(ex) as.character(ex[["@id"]]), character(1)), + paste0(csta2026_prefix, ":", examples$code) + ) + + expanded$nodes <- lapply(expanded$nodes, function(node) { + iri <- as.character(node[["@id"]]) + if (!is.null(example_iris[[iri]])) { + node[["cybed:hasExample"]] <- purrr::map(unname(example_iris[[iri]]), + \(eid) list(`@id` = eid)) + } + node + }) + + unit_nodes <- units |> + purrr::pmap(function(unit_id, level, tier, concept, concept_code, + specialty_area_code, ...) { + child_standards <- standards$code[standards$level == level & + startsWith(standards$code, + paste0(level, "-", concept_code, "-"))] + child_standards <- extend_role_element_ids(child_standards, expanded$subnode_index) + + unit_meta <- list(`csta2026:tier` = tier) + if (!is.na(specialty_area_code) && nzchar(specialty_area_code)) { + unit_meta[["csta2026:specialtyArea"]] <- specialty_area_code + } + + build_organizing_unit_node( + unit_id = unit_id, + unit_name = paste(level, concept, sep = " / "), + framework_prefix = csta2026_prefix, + framework_subtype = "StandardGroup", + is_role = FALSE, + description = paste0(unname(csta2026_level_labels[level]), ", ", concept), + element_ids = child_standards, + framework_id = csta2026_framework_id, + metadata = unit_meta, + unit_iri_prefix = unit_iri_prefix + ) + }) + + list(framework = framework_node, roles = unit_nodes, + elements = c(expanded$nodes, example_nodes), prefix = csta2026_prefix) +} diff --git a/scripts/_assemble-cybok.R b/scripts/_assemble-cybok.R new file mode 100644 index 0000000..9dce879 --- /dev/null +++ b/scripts/_assemble-cybok.R @@ -0,0 +1,120 @@ +# JSON-LD adapter for the Cyber Security Body of Knowledge (CyBOK) v1.1.0, +# published by the National Cyber Security Centre (NCSC) under the Open +# Government Licence v3.0. +# +# Sourced by scripts/020-assemble-jsonld.R, which reads the staged tables and +# calls build_cybok_parts(). Kept in its own file so the test suite can drive +# the adapter with a small synthetic fixture instead of the staged data. +# Requires the cybedtools JSON-LD constructors (build_*_node) to be loaded. +# +# scripts/ is .Rbuildignore'd, so nothing here ships in the package. +# +# CyBOK is a body of knowledge, not a workforce framework: it asserts no roles +# and no proficiency levels, so nothing here asserts cybed:Role. Its own +# structure is Knowledge Area, then Topic, then Indicative Material, read by +# scripts/010-ingest-cybok.R from each KA's Knowledge Tree. +# +# Mapping onto the cybed: layer. +# +# Knowledge Area cybed:OrganizingUnit, subtype cybok:KnowledgeArea, +# keyed by the acronym CyBOK's A-to-Z uses for it +# (RMG, AAA, ...). Name as printed in Figure 2 of the +# Introduction. Its category (one of the five of +# Figure 2) is the literal cybok:category, and the +# KA's own document version is schema:version, because +# CyBOK versions every KA separately. +# Topic cybed:RoleElement, subtype cybok:Topic, keyed by +# the KA acronym and the Topic's place in its tree +# (RMG-01). The element text is the Topic as printed +# in the tree. CyBOK prints no Topic codes, so the +# number is cybedtools' own and follows the tree from +# top to bottom. +# Indicative Material cybed:Subpoint of its Topic (cybok:RMG-01.sub.1), +# also typed cybok:IndicativeMaterial, linked back by +# cybed:elaborates. cybed:Subpoint is the package's +# class for a child the framework itself enumerates +# under a parent element, which is what CyBOK defines +# Indicative Material to be. The sub-point parser is +# off: nothing is split out of any text, and every +# Subpoint here is a node the tree draws. +# +# A KA's cybed:hasElement lists its Topics and their Indicative Material, as +# frameworks with Subpoints do elsewhere. + +cybok_framework_id <- "cybok-v1.1.0" +cybok_prefix <- "cybok" + +#' Build the CyBOK framework, unit and element nodes. +#' +#' @param knowledge_areas Tibble: ka_acronym, ka_name, category_name, +#' ka_order, version. +#' @param topics Tibble: ka_acronym, topic_id, ordinal, title. +#' @param indicative_material Tibble: ka_acronym, topic_id, ordinal, term. +#' @param prov List, the parsed provenance manifest. +#' @return List with framework, roles, elements and prefix, the shape the +#' other assemblers return. +build_cybok_parts <- function(knowledge_areas, topics, indicative_material, prov) { + framework_node <- build_framework_node( + framework_id = cybok_framework_id, + framework_name = prov$framework_version, + framework_prefix = cybok_prefix, + version = prov$framework_version, + publisher = prov$publisher, + jurisdiction = "UK", + sector = "general", + specificity = "cybersecurity-specific", + license = prov$licensing$source_license, + date_published = as.character(prov$framework_date), + attribution = prov$licensing$attribution + ) + + topic_nodes <- topics |> + purrr::pmap(function(topic_id, title, ...) { + build_role_element_node( + element_id = topic_id, + framework_prefix = cybok_prefix, + framework_element_type = "Topic", + element_text = title, + framework_id = cybok_framework_id + ) + }) + + im_nodes <- indicative_material |> + purrr::pmap(function(topic_id, ordinal, term, ...) { + build_subpoint_node( + parent_element_id = topic_id, + ordinal = ordinal, + text = term, + framework_prefix = cybok_prefix, + framework_id = cybok_framework_id, + parent_subtype = "IndicativeMaterial" + ) + }) + + topic_rank <- setNames(topics$ordinal, topics$topic_id) + ka_nodes <- knowledge_areas |> + dplyr::arrange(ka_order) |> + purrr::pmap(function(ka_acronym, ka_name, category_name, version, ...) { + ka_topics <- topics[topics$ka_acronym == ka_acronym, ] + ka_topics <- ka_topics[order(ka_topics$ordinal), ] + ka_im <- indicative_material[indicative_material$ka_acronym == ka_acronym, ] + ka_im <- ka_im[order(topic_rank[ka_im$topic_id], ka_im$ordinal), ] + build_organizing_unit_node( + unit_id = ka_acronym, + unit_name = ka_name, + framework_prefix = cybok_prefix, + framework_subtype = "KnowledgeArea", + is_role = FALSE, + element_ids = c(ka_topics$topic_id, + paste0(ka_im$topic_id, ".sub.", ka_im$ordinal)), + framework_id = cybok_framework_id, + metadata = list(`cybok:category` = category_name, + `schema:version` = as.character(version)) + ) + }) + + list(framework = framework_node, + roles = ka_nodes, + elements = c(topic_nodes, im_nodes), + prefix = cybok_prefix) +} diff --git a/scripts/_assemble-scywf.R b/scripts/_assemble-scywf.R new file mode 100644 index 0000000..14be83f --- /dev/null +++ b/scripts/_assemble-scywf.R @@ -0,0 +1,208 @@ +# JSON-LD adapter for the Saudi Cybersecurity Workforce Framework +# (SCyWF – 1.5: 2026), issued by the National Cybersecurity Authority (NCA). +# +# Sourced by scripts/020-assemble-jsonld.R, which reads the staged tables and +# calls build_scywf_parts(). Kept in its own file so the test suite can drive +# the adapter with a small synthetic fixture instead of the staged data. +# Requires the cybedtools JSON-LD constructors (build_*_node, +# build_related_unit_metadata) to be loaded. +# +# scripts/ is .Rbuildignore'd, so nothing here ships in the package. +# +# NCA's permission is conditional. Everything NCA published is carried +# verbatim: codes, titles, descriptions and statement text, copied by +# scripts/010-ingest-scywf.R and checked there against an independent +# extraction. Everything cybedtools derives is kept on predicates cybedtools +# mints for the purpose, so it can never be read as NCA content. +# +# Mapping onto the cybed: layer, following NICE and DCWF. +# +# Task, Knowledge and cybed:RoleElement, subtypes scywf:TaskStatement, +# Skill statements scywf:KnowledgeStatement and scywf:SkillStatement. +# The element id is the code as printed in Appendix +# B (T0036, K0001, S0001) and the element text is the +# statement as printed. Skills also carry NCA's +# "Technical?" column verbatim as scywf:technical +# ("Yes" or "No"). +# Job role cybed:Role, subtype scywf:JobRole, keyed by the +# printed Job Role ID (CARD-CA-001). Name and +# description are the card's own. cybed:hasElement +# links the role to every code printed on its card. +# Competency area cybed:OrganizingUnit only, subtype +# scywf:CompetencyArea, keyed by its CA code (CA001), +# with Appendix C's name and description. NCA +# publishes no statement membership for a competency +# area, so it has no cybed:hasElement. +# Specialty area, cybed:OrganizingUnit only, subtypes +# category scywf:SpecialtyArea and scywf:Category. The ID is +# the segment NCA uses inside its job role IDs, and +# the name is the card's Category or Specialty Area +# field. Category ICSOT and specialty area ICSOT +# share one ID, so both kinds of unit carry an IRI +# discriminator (scywf:category-ICSOT, +# scywf:specialty-ICSOT) and keep the printed ID as +# schema:identifier. +# +# Unit IRIs and statement IRIs cannot collide: job role IDs, CA codes and +# statement codes have different shapes, and the group units carry a +# discriminator. docs/framework-invariants.yml therefore declares no +# unit_iri_prefix for scywf. +# +# Derived edges (cybedtools, not NCA): +# +# scywf:inSpecialtyArea job role -> its specialty area unit +# scywf:inCategory specialty area unit -> its category unit +# +# NCA prints each role's category and specialty area as text fields on the +# role card and encodes them in the role ID. The two predicates turn that +# into graph edges. They are cybedtools terms, minted under the cybed +# namespace, and state a hierarchy cybedtools built. +# +# Card literals (verbatim): +# +# scywf:competencyAreasText the card's Competency Areas field as printed, +# including cards whose field names no CA code +# ("All competencies relevant to the fields of +# research.") +# scywf:competencyAreasNote the footnote NCA attaches to that field with +# an asterisk, where it does +# +# The card's CA codes are also carried as cybed:relatedUnit links to the +# competency area units, which is the plain "the publisher relates these +# units" edge. +# +# Sub-point parser: disabled. SCyWF's statements are published statements, +# and a parser-split fragment of one would be text NCA did not print. + +scywf_framework_id <- "scywf-1.5" +scywf_prefix <- "scywf" + +scywf_statement_subtype <- c( + task = "TaskStatement", + knowledge = "KnowledgeStatement", + skill = "SkillStatement" +) + +#' Build the SCyWF framework, unit and element nodes. +#' +#' @param categories Tibble: category_id, category_name. +#' @param specialty_areas Tibble: specialty_area_id, specialty_area_name, +#' category_id. +#' @param roles Tibble, one row per job role card: role_id, role_name, +#' specialty_area_id, description, competency_areas_text, +#' competency_areas_note. +#' @param statements Tibble: statement_id, statement_type, text, technical. +#' @param role_statements Tibble: role_id, statement_id, statement_type, +#' ordinal. +#' @param competency_areas Tibble: ca_id, ca_name, description. +#' @param role_competency_areas Tibble: role_id, ca_id, ordinal. +#' @param prov List, the parsed provenance manifest. +#' @return List with framework, roles, elements and prefix, the shape the +#' other assemblers return. +build_scywf_parts <- function(categories, specialty_areas, roles, statements, + role_statements, competency_areas, + role_competency_areas, prov) { + framework_node <- build_framework_node( + framework_id = scywf_framework_id, + framework_name = prov$framework_version, + framework_prefix = scywf_prefix, + version = prov$framework_version, + publisher = prov$publisher, + jurisdiction = "SA", + sector = "civilian", + specificity = "cybersecurity-specific", + license = prov$licensing$source_license, + date_published = as.character(prov$framework_date), + attribution = prov$licensing$attribution + ) + + element_nodes <- statements |> + purrr::pmap(function(statement_id, statement_type, text, technical, ...) { + node <- build_role_element_node( + element_id = statement_id, + framework_prefix = scywf_prefix, + framework_element_type = unname(scywf_statement_subtype[statement_type]), + element_text = text, + framework_id = scywf_framework_id + ) + if (!is.na(technical) && nzchar(technical)) { + node[["scywf:technical"]] <- technical + } + node + }) + + type_rank <- c(task = 1L, knowledge = 2L, skill = 3L) + role_nodes <- roles |> + purrr::pmap(function(role_id, role_name, specialty_area_id, description, + competency_areas_text, competency_areas_note, ...) { + rs <- role_statements[role_statements$role_id == role_id, ] + rs <- rs[order(type_rank[rs$statement_type], rs$ordinal), ] + ca <- role_competency_areas[role_competency_areas$role_id == role_id, ] + ca <- ca[order(ca$ordinal), ] + + meta <- list(`scywf:inSpecialtyArea` = + list(`@id` = paste0(scywf_prefix, ":specialty-", specialty_area_id))) + meta <- c(meta, build_related_unit_metadata(ca$ca_id, scywf_prefix)) + meta[["scywf:competencyAreasText"]] <- competency_areas_text + if (!is.na(competency_areas_note) && nzchar(competency_areas_note)) { + meta[["scywf:competencyAreasNote"]] <- competency_areas_note + } + + build_role_node( + role_id = role_id, + role_name = role_name, + framework_prefix = scywf_prefix, + framework_role_type = "JobRole", + description = description, + element_ids = unique(rs$statement_id), + framework_id = scywf_framework_id, + metadata = meta + ) + }) + + ca_nodes <- competency_areas |> + purrr::pmap(function(ca_id, ca_name, description, ...) { + build_organizing_unit_node( + unit_id = ca_id, + unit_name = ca_name, + framework_prefix = scywf_prefix, + framework_subtype = "CompetencyArea", + is_role = FALSE, + description = description, + framework_id = scywf_framework_id + ) + }) + + specialty_nodes <- specialty_areas |> + purrr::pmap(function(specialty_area_id, specialty_area_name, category_id, ...) { + build_organizing_unit_node( + unit_id = specialty_area_id, + unit_name = specialty_area_name, + framework_prefix = scywf_prefix, + framework_subtype = "SpecialtyArea", + is_role = FALSE, + framework_id = scywf_framework_id, + metadata = list(`scywf:inCategory` = + list(`@id` = paste0(scywf_prefix, ":category-", category_id))), + unit_iri_prefix = "specialty-" + ) + }) + + category_nodes <- categories |> + purrr::pmap(function(category_id, category_name, ...) { + build_organizing_unit_node( + unit_id = category_id, + unit_name = category_name, + framework_prefix = scywf_prefix, + framework_subtype = "Category", + is_role = FALSE, + framework_id = scywf_framework_id, + unit_iri_prefix = "category-" + ) + }) + + list(framework = framework_node, + roles = c(role_nodes, ca_nodes, specialty_nodes, category_nodes), + elements = element_nodes, + prefix = scywf_prefix) +} diff --git a/scripts/_release-common.R b/scripts/_release-common.R index db38c2c..f7d656d 100644 --- a/scripts/_release-common.R +++ b/scripts/_release-common.R @@ -734,13 +734,20 @@ release_scope <- function(policy_value) { #' the match is exact first and then on the version suffix. A slug with no row, #' or with more than one candidate row, stops the export: a shipped file #' without licence terms is the one thing this stage must never write. -release_license_row <- function(licenses, slug) { +#' +#' `reserved` names slugs that are frameworks in their own right. A licence +#' row whose slug is one of them is never a version of another slug: csta-2026 +#' is its own framework, not a version of csta, so it must not compete with +#' csta-2017 when csta is looked up. +release_license_row <- function(licenses, slug, reserved = character()) { exact <- licenses[licenses$slug == slug, , drop = FALSE] if (nrow(exact) == 1L) { return(exact) } - candidates <- licenses[startsWith(licenses$slug, paste0(slug, "-")), , drop = FALSE] + candidates <- licenses[startsWith(licenses$slug, paste0(slug, "-")) & + !licenses$slug %in% setdiff(reserved, slug), , + drop = FALSE] if (nrow(candidates) == 1L) { return(candidates) } diff --git a/tests/testthat/test-conformance-goldens.R b/tests/testthat/test-conformance-goldens.R new file mode 100644 index 0000000..db5a73b --- /dev/null +++ b/tests/testthat/test-conformance-goldens.R @@ -0,0 +1,185 @@ +# Every function's output against inst/conformance/fixture.nt must exactly +# match its golden CSV. This is the parity contract a future Python port is +# built against (inst/conformance/README.md documents the exact format). +# Goldens are regenerated by scripts/040-build-goldens.R, never hand-edited. + +conformance_dir <- system.file("conformance", package = "cybedtools") +if (!nzchar(conformance_dir)) { + # Running against the source tree rather than an installed package + # (devtools::load_all()); fall back to the checkout path. + conformance_dir <- testthat::test_path("..", "..", "inst", "conformance") +} + +skip_if_no_conformance_fixture <- function() { + skip_if_not_installed("rdflib") + skip_if_not( + file.exists(file.path(conformance_dir, "fixture.nt")), + "conformance fixture not found; run scripts/040-build-goldens.R" + ) +} + +read_golden <- function(name) { + readr::read_csv( + file.path(conformance_dir, "goldens", paste0(name, ".csv")), + col_types = readr::cols(.default = "c"), + na = "" + ) +} + +as_char_frame <- function(df) { + df <- as.data.frame(lapply(df, as.character), stringsAsFactors = FALSE) + df[is.na(df)] <- "" + df +} + +test_that("framework_metadata matches its golden", { + skip_if_no_conformance_fixture() + rdf <- rdflib::rdf_parse(file.path(conformance_dir, "fixture.nt"), format = "ntriples") + + result <- framework_metadata(rdf) |> dplyr::arrange(.data$framework) + golden <- read_golden("framework_metadata") + + expect_equal(as_char_frame(result), as_char_frame(golden)) +}) + +test_that("unit_element_bindings matches its golden", { + skip_if_no_conformance_fixture() + rdf <- rdflib::rdf_parse(file.path(conformance_dir, "fixture.nt"), format = "ntriples") + + result <- unit_element_bindings(rdf) |> dplyr::arrange(.data$role, .data$element) + golden <- read_golden("unit_element_bindings") + + expect_equal(as_char_frame(result), as_char_frame(golden)) +}) + +test_that("unit_relation_bindings matches its golden", { + skip_if_no_conformance_fixture() + rdf <- rdflib::rdf_parse(file.path(conformance_dir, "fixture.nt"), format = "ntriples") + + result <- unit_relation_bindings(rdf) |> + dplyr::arrange(.data$from_unit, .data$to_unit, .data$relation) + golden <- read_golden("unit_relation_bindings") + + expect_equal(as_char_frame(result), as_char_frame(golden)) +}) + +test_that("framework_similarity matches its golden", { + skip_if_no_conformance_fixture() + rdf <- rdflib::rdf_parse(file.path(conformance_dir, "fixture.nt"), format = "ntriples") + + result <- framework_similarity(rdf, from = "fixture-wf1", to = "fixture-wf2", n = 5) |> + dplyr::mutate(score = round(.data$score, 10)) |> + dplyr::arrange(.data$from_unit, .data$rank) + golden <- read_golden("framework_similarity") |> + dplyr::mutate(rank = as.integer(.data$rank)) + + expect_equal(nrow(result), nrow(golden)) + expect_equal(as.character(result$from_unit), golden$from_unit) + expect_equal(as.character(result$to_unit), golden$to_unit) + expect_equal(as.numeric(result$score), as.numeric(golden$score), tolerance = 1e-9) + expect_equal(as.character(result$strength), golden$strength) + expect_equal(result$rank, golden$rank) +}) + +test_that("cybed_license matches its golden", { + skip_if_no_conformance_fixture() + + result <- cybed_license() |> dplyr::arrange(.data$slug) + golden <- read_golden("cybed_license") + + expect_equal(as_char_frame(result), as_char_frame(golden)) +}) + +test_that("framework_summary matches its golden", { + skip_if_no_conformance_fixture() + + result <- cybedtools::framework_summary |> dplyr::arrange(.data$framework_slug) + golden <- read_golden("framework_summary") + + expect_equal(as_char_frame(result), as_char_frame(golden)) +}) + +test_that("role_framework_bindings matches its golden", { + skip_if_no_conformance_fixture() + rdf <- rdflib::rdf_parse(file.path(conformance_dir, "fixture.nt"), format = "ntriples") + + result <- role_framework_bindings(rdf) |> dplyr::arrange(.data$role, .data$framework) + golden <- read_golden("role_framework_bindings") + + expect_equal(as_char_frame(result), as_char_frame(golden)) +}) + +test_that("organizing_unit_framework_bindings matches its golden", { + skip_if_no_conformance_fixture() + rdf <- rdflib::rdf_parse(file.path(conformance_dir, "fixture.nt"), format = "ntriples") + + result <- organizing_unit_framework_bindings(rdf) |> + dplyr::arrange(.data$unit, .data$framework) + golden <- read_golden("organizing_unit_framework_bindings") + + expect_equal(as_char_frame(result), as_char_frame(golden)) +}) + +test_that("element_framework_bindings matches its golden", { + skip_if_no_conformance_fixture() + rdf <- rdflib::rdf_parse(file.path(conformance_dir, "fixture.nt"), format = "ntriples") + + result <- element_framework_bindings(rdf) |> + dplyr::arrange(.data$element, .data$framework) + golden <- read_golden("element_framework_bindings") + + expect_equal(as_char_frame(result), as_char_frame(golden)) +}) + +test_that("example_framework_bindings matches its golden", { + skip_if_no_conformance_fixture() + rdf <- rdflib::rdf_parse(file.path(conformance_dir, "fixture.nt"), format = "ntriples") + + result <- example_framework_bindings(rdf) |> + dplyr::arrange(.data$example, .data$framework) + golden <- read_golden("example_framework_bindings") + + expect_equal(as_char_frame(result), as_char_frame(golden)) +}) + +test_that("subpoint_framework_bindings matches its golden", { + skip_if_no_conformance_fixture() + rdf <- rdflib::rdf_parse(file.path(conformance_dir, "fixture.nt"), format = "ntriples") + + result <- subpoint_framework_bindings(rdf) |> + dplyr::arrange(.data$subpoint, .data$framework) + golden <- read_golden("subpoint_framework_bindings") + + expect_equal(as_char_frame(result), as_char_frame(golden)) +}) + +test_that("element_text matches its golden", { + skip_if_no_conformance_fixture() + rdf <- rdflib::rdf_parse(file.path(conformance_dir, "fixture.nt"), format = "ntriples") + + result <- element_text(rdf) |> dplyr::arrange(.data$element) + golden <- read_golden("element_text") + + expect_equal(as_char_frame(result), as_char_frame(golden)) +}) + +test_that("golden CSVs use LF line endings and no literal NA", { + skip_if_no_conformance_fixture() + + for (name in c("framework_metadata", "unit_element_bindings", + "unit_relation_bindings", "framework_similarity", + "cybed_license", "framework_summary", + "role_framework_bindings", "organizing_unit_framework_bindings", + "element_framework_bindings", "example_framework_bindings", + "subpoint_framework_bindings", "element_text")) { + path <- file.path(conformance_dir, "goldens", paste0(name, ".csv")) + raw <- readBin(path, "raw", file.size(path)) + expect_false(any(raw == as.raw(0x0d)), info = paste(name, "carries a CR byte")) + + text <- readLines(path, warn = FALSE) + expect_false( + any(grepl("(^|,)NA($|,)", text)), + info = paste(name, "carries a literal NA field") + ) + } +}) diff --git a/tests/testthat/test-cross-framework-keys.R b/tests/testthat/test-cross-framework-keys.R index 0412a65..3c1f784 100644 --- a/tests/testthat/test-cross-framework-keys.R +++ b/tests/testthat/test-cross-framework-keys.R @@ -17,7 +17,7 @@ # framework_metadata, role_framework_bindings, # organizing_unit_framework_bindings, element_framework_bindings, # example_framework_bindings, subpoint_framework_bindings, -# role_element_bindings +# unit_element_bindings looks_like_full_iri <- function(x) { x <- x[!is.na(x)] @@ -45,8 +45,8 @@ key_safety_cases <- list( list(name = "subpoint_framework_bindings", fn = function(rdf) subpoint_framework_bindings(rdf), ids = "subpoint"), - list(name = "role_element_bindings", - fn = function(rdf) role_element_bindings(rdf), + list(name = "unit_element_bindings", + fn = function(rdf) unit_element_bindings(rdf), ids = c("role", "element")) ) @@ -113,13 +113,13 @@ test_that("stripping identifiers to bare local codes collapses two frameworks", expect_equal(anyDuplicated(elements$element), 0L) }) -test_that("role_element_bindings pairs stay resolvable to a single framework", { - # role_element_bindings() is the one helper with no framework column. It +test_that("unit_element_bindings pairs stay resolvable to a single framework", { + # unit_element_bindings() is the one helper with no framework column. It # returns full IRIs on both sides, so each pair still resolves, but only # through a join. Documented here so the reliance is explicit. rdf <- make_shared_code_fixture_graph() - pairs <- role_element_bindings(rdf) + pairs <- unit_element_bindings(rdf) expect_false("framework" %in% names(pairs)) expect_true(looks_like_full_iri(pairs$role)) expect_true(looks_like_full_iri(pairs$element)) diff --git a/tests/testthat/test-csta2026-assembler.R b/tests/testthat/test-csta2026-assembler.R new file mode 100644 index 0000000..bd5e381 --- /dev/null +++ b/tests/testthat/test-csta2026-assembler.R @@ -0,0 +1,270 @@ +# Tests for the csta-2026 ingest helpers (scripts/010-ingest-csta2026.R) and +# JSON-LD adapter (scripts/_assemble-csta2026.R). +# +# Both scripts guard or omit any top-level pipeline run, so source()-ing them +# loads functions only. The fixture below is synthetic and mirrors the shape +# of the Standards Explorer JSON: no staged framework data is read, except by +# the last test, which skips when the staged tables are absent. +# +# scripts/ is .Rbuildignore'd, so these tests skip under R CMD check on the +# built tarball and run under devtools::test() in the source tree. + +source_csta2026_scripts <- function() { + for (pkg in c("jsonlite", "readr", "yaml", "digest", "stringr", "here")) { + testthat::skip_if_not_installed(pkg) + } + ingest <- testthat::test_path("..", "..", "scripts", "010-ingest-csta2026.R") + adapter <- testthat::test_path("..", "..", "scripts", "_assemble-csta2026.R") + if (!file.exists(ingest) || !file.exists(adapter)) { + testthat::skip("csta-2026 scripts not available (built-tarball check)") + } + env <- new.env(parent = globalenv()) + suppressPackageStartupMessages(suppressWarnings(source(ingest, local = env))) + suppressPackageStartupMessages(suppressWarnings(source(adapter, local = env))) + env +} + +fixture_record <- function(code, grade, concept, subconcept = "", + area = "", subarea = "", title = "A standard.", + boundaries = list("Students should do the thing."), + examples = list(), ai = "No") { + list( + code = code, grade = grade, concept = concept, subconcept = subconcept, + specialtyArea = area, specialtySubArea = subarea, title = title, + boundaries = boundaries, ai_standard = ai, + pillars = list(list(label = "Computational Thinking", + description = "6. Define computational problems.")), + dispositions = list("Curiosity"), + examples = examples + ) +} + +fixture_example <- function(type, text) list(type = type, icon = "", description = text) + +# Two foundational groups at MS, two specialty areas, one of which (X+CS) is +# published at Specialty I only. A crossing would give 2 x 2 specialty units; +# the observed pairs give 3. +csta2026_fixture <- function() { + list( + fixture_record("MS-ALG-PS-01", "MS", "Algorithms & Design", + "Algorithmic Problem Solving", + title = "Design an algorithm.", + boundaries = list("Students should design.", "Students are not expected to prove."), + examples = list(fixture_example("Unplugged", "Students sort cards."), + fixture_example("Computer-based", "Students write a flowchart.")), + ai = "Yes"), + fixture_record("MS-ALG-ML-02", "MS", "Algorithms & Design", "Machine Learning"), + fixture_record("MS-SYS-SE-03", "MS", "Systems & Security", "Security", + examples = list(fixture_example("Teacher choice", "Students review a password policy."))), + fixture_record("S1-CYB-ND-01", "Specialty-I", "Cybersecurity", + area = "Cybersecurity", subarea = "Network Theory & Design", + title = "Analyze network protocols.", + examples = list(fixture_example("Unplugged", "Students classify protocols."))), + fixture_record("S2-CYB-ND-01", "Specialty-II", "Cybersecurity", + area = "Cybersecurity", subarea = "Network Theory & Design", + title = "Integrate security features."), + fixture_record("S1-XCS-XC-01", "Specialty-I", "X+CS", + area = "X+CS", subarea = "X+CS", + title = "Apply computing in another discipline.") + ) +} + +build_fixture_parts <- function(env) { + records <- csta2026_fixture() + standards <- env$extract_csta2026_standards(records) + boundaries <- env$extract_csta2026_boundaries(records) + examples <- env$extract_csta2026_examples(records) + units <- env$build_csta2026_units(standards) + prov <- list( + framework_version = "2026 CSTA PK-12 Computer Science Standards", + version_date = "2026-07", + doi = "10.1145/3820482", + suggested_citation = "Computer Science Teachers Association. (2026). Fixture citation.", + source = list(publisher = "Computer Science Teachers Association (CSTA)"), + licensing = list(source_license = "CC BY-NC-SA 4.0") + ) + list( + standards = standards, boundaries = boundaries, examples = examples, + units = units, + parts = env$build_csta2026_parts(standards, boundaries, examples, units, prov) + ) +} + +node_ids <- function(nodes) vapply(nodes, \(n) as.character(n[["@id"]]), character(1)) + +node_by_id <- function(nodes, id) nodes[[match(id, node_ids(nodes))]] + +link_ids <- function(links) vapply(links, \(l) as.character(l[["@id"]]), character(1)) + +# --------------------------------------------------------------------------- +# Extraction +# --------------------------------------------------------------------------- + +test_that("both tiers normalise onto one concept / subconcept pair", { + env <- source_csta2026_scripts() + s <- env$extract_csta2026_standards(csta2026_fixture()) + + cyb <- s[s$code == "S1-CYB-ND-01", ] + expect_equal(cyb$tier, "specialty") + expect_equal(cyb$concept, "Cybersecurity") + expect_equal(cyb$subconcept, "Network Theory & Design") + expect_equal(cyb$specialty_area_code, "CYB") + expect_equal(cyb$source_section, "S1.Cybersecurity.Network Theory & Design") + + ms <- s[s$code == "MS-ALG-PS-01", ] + expect_equal(ms$tier, "foundational") + expect_true(is.na(ms$specialty_area_code)) + expect_equal(ms$source_section, "MS.Algorithms & Design.Algorithmic Problem Solving") + expect_true(ms$ai_standard) + expect_equal(ms$practices, "CT6") +}) + +test_that("units come from observed pairs, never from a crossing", { + env <- source_csta2026_scripts() + s <- env$extract_csta2026_standards(csta2026_fixture()) + units <- env$build_csta2026_units(s) + + expect_setequal(units$unit_id, + c("MS-ALG", "MS-SYS", "S1-CYB", "S2-CYB", "S1-XCS")) + expect_false("S2-XCS" %in% units$unit_id) + expect_equal(sum(units$tier == "foundational"), 2L) + expect_equal(sum(units$tier == "specialty"), 3L) +}) + +# --------------------------------------------------------------------------- +# Assembly +# --------------------------------------------------------------------------- + +test_that("the adapter emits one StandardGroup unit per observed pair", { + env <- source_csta2026_scripts() + built <- build_fixture_parts(env) + roles <- built$parts$roles + + expect_length(roles, 5L) + for (unit in roles) { + expect_equal(unit[["@type"]], c("csta2026:StandardGroup", "cybed:OrganizingUnit")) + expect_false("cybed:Role" %in% unit[["@type"]]) + } + cyb <- node_by_id(roles, "csta2026:S1-CYB") + expect_equal(cyb[["csta2026:tier"]], "specialty") + expect_equal(cyb[["csta2026:specialtyArea"]], "CYB") + expect_equal(link_ids(cyb[["cybed:hasElement"]]), "csta2026:S1-CYB-ND-01") + + alg <- node_by_id(roles, "csta2026:MS-ALG") + expect_setequal(link_ids(alg[["cybed:hasElement"]]), + c("csta2026:MS-ALG-PS-01", "csta2026:MS-ALG-ML-02")) + expect_null(alg[["csta2026:specialtyArea"]]) +}) + +test_that("standards keep CSTA's code and text verbatim and carry the tier literals", { + env <- source_csta2026_scripts() + built <- build_fixture_parts(env) + el <- node_by_id(built$parts$elements, "csta2026:S1-CYB-ND-01") + + expect_equal(el[["@type"]], c("csta2026:Standard", "cybed:RoleElement")) + expect_equal(el[["cybed:elementText"]], "Analyze network protocols.") + expect_equal(el[["cybed:sourceSection"]], "S1.Cybersecurity.Network Theory & Design") + expect_equal(el[["csta2026:tier"]], "specialty") + expect_equal(el[["csta2026:specialtyArea"]], "CYB") + expect_equal(el[["csta2026:subconcept"]], "Network Theory & Design") + expect_false(el[["csta2026:aiStandard"]]) + + ms <- node_by_id(built$parts$elements, "csta2026:MS-ALG-PS-01") + expect_true(ms[["csta2026:aiStandard"]]) + expect_null(ms[["csta2026:specialtyArea"]]) +}) + +test_that("implementation examples are Examples reached by hasExample, not hasElement", { + env <- source_csta2026_scripts() + built <- build_fixture_parts(env) + elements <- built$parts$elements + + is_example <- vapply(elements, \(n) "cybed:Example" %in% n[["@type"]], logical(1)) + expect_equal(sum(is_example), nrow(built$examples)) + expect_equal(sum(is_example), 4L) + + example_iris <- node_ids(elements[is_example]) + for (ex in elements[is_example]) { + expect_equal(ex[["@type"]], c("cybed:Example", "cybed:RoleElement")) + } + + parent <- node_by_id(elements, "csta2026:MS-ALG-PS-01") + expect_equal(link_ids(parent[["cybed:hasExample"]]), + c("csta2026:MS-ALG-PS-01.example.1", "csta2026:MS-ALG-PS-01.example.2")) + first <- node_by_id(elements, "csta2026:MS-ALG-PS-01.example.1") + expect_equal(first[["cybed:elementText"]], "Students sort cards.") + expect_equal(first[["csta2026:exampleType"]], "Unplugged") + + # No unit reaches an Example through cybed:hasElement. + unit_links <- unlist(lapply(built$parts$roles, \(u) link_ids(u[["cybed:hasElement"]]))) + expect_length(intersect(unit_links, example_iris), 0L) + + # A standard with no published examples carries no hasExample. + expect_null(node_by_id(elements, "csta2026:MS-ALG-ML-02")[["cybed:hasExample"]]) +}) + +test_that("boundary statements are literals on the standard, never Examples", { + env <- source_csta2026_scripts() + built <- build_fixture_parts(env) + elements <- built$parts$elements + + parent <- node_by_id(elements, "csta2026:MS-ALG-PS-01") + expect_equal(parent[["csta2026:boundaryStatement"]], + c("Students should design.", "Students are not expected to prove.")) + + example_texts <- unlist(lapply( + Filter(\(n) "cybed:Example" %in% n[["@type"]], elements), + \(n) n[["cybed:elementText"]] + )) + expect_false(any(built$boundaries$text %in% example_texts)) +}) + +test_that("the adapter never touches csta-2017's prefix or namespace", { + env <- source_csta2026_scripts() + built <- build_fixture_parts(env) + + all_ids <- c(node_ids(built$parts$roles), node_ids(built$parts$elements)) + expect_true(all(startsWith(all_ids, "csta2026:"))) + expect_false(any(startsWith(all_ids, "csta:"))) + expect_equal(built$parts$prefix, "csta2026") + expect_equal(as.character(built$parts$framework[["@id"]]), "cybed:framework/csta-2026") + + # csta-2017 keeps its own namespace and a context of its own. + expect_equal(build_jsonld_context("csta")$csta, + "https://csteachers.org/k12standards/terms#") + expect_false("csta2026" %in% names(build_jsonld_context("csta"))) + expect_true("csta2026" %in% names(build_jsonld_context("csta2026"))) + expect_false(identical(build_jsonld_context("csta2026")$csta2026, + build_jsonld_context("csta")$csta)) +}) + +test_that("the framework node is pedagogical, US, with CSTA's citation and DOI", { + env <- source_csta2026_scripts() + fw <- build_fixture_parts(env)$parts$framework + + expect_equal(fw[["@type"]], c("csta2026:Framework", "cybed:Framework")) + expect_equal(fw[["cybed:jurisdiction"]], "US") + expect_equal(fw[["cybed:specificity"]], "general-computing") + expect_equal(fw[["schema:license"]], "CC BY-NC-SA 4.0") + expect_match(fw[["schema:creditText"]], "Computer Science Teachers Association", fixed = TRUE) + expect_match(fw[["schema:creditText"]], "10.1145/3820482", fixed = TRUE) +}) + +# --------------------------------------------------------------------------- +# Staged data, when present +# --------------------------------------------------------------------------- + +test_that("the staged tables yield 53 units and 27 Cybersecurity standards", { + env <- source_csta2026_scripts() + tables <- testthat::test_path("..", "..", "data", "raw", "csta-2026", "tables") + skip_if(!file.exists(file.path(tables, "units.csv")), "csta-2026 tables not staged.") + + units <- readr::read_csv(file.path(tables, "units.csv"), show_col_types = FALSE) + standards <- readr::read_csv(file.path(tables, "standards.csv"), show_col_types = FALSE) + expect_equal(nrow(units), 53L) + expect_equal(sum(units$tier == "foundational"), 40L) + expect_equal(sum(units$tier == "specialty"), 13L) + expect_false("S2-XCS" %in% units$unit_id) + expect_equal(nrow(standards), 331L) + expect_equal(sum(standards$specialty_area_code %in% "CYB"), 27L) +}) diff --git a/tests/testthat/test-cybed-fetch.R b/tests/testthat/test-cybed-fetch.R new file mode 100644 index 0000000..fb3eefd --- /dev/null +++ b/tests/testthat/test-cybed-fetch.R @@ -0,0 +1,213 @@ +# cybed_fetch() / load_graph() against the mock release in +# inst/conformance/mock-release/1.0.0/. Every URL here is a file:// URL, so +# no test in this file ever touches the network (enforced by never setting +# cybedtools.release_url / CYBEDTOOLS_RELEASE_URL to anything else). + +conformance_dir <- system.file("conformance", package = "cybedtools") +if (!nzchar(conformance_dir)) { + conformance_dir <- testthat::test_path("..", "..", "inst", "conformance") +} +mock_release_dir <- file.path(conformance_dir, "mock-release") + +skip_if_no_mock_release <- function() { + skip_if_not_installed("rdflib") + skip_if_not_installed("digest") + skip_if_not( + dir.exists(file.path(mock_release_dir, "1.0.0")), + "mock release not found; run scripts/040-build-goldens.R" + ) +} + +mock_release_url <- function() { + # file:// URL to the mock release directory. normalizePath() with + # winslash = "/" so the path works as a URL on Windows too. + paste0("file:///", normalizePath(mock_release_dir, winslash = "/", mustWork = TRUE)) +} + +with_mock_release <- function(code) { + old_opt <- getOption("cybedtools.release_url") + old_env <- Sys.getenv("CYBEDTOOLS_RELEASE_URL", unset = NA_character_) + options(cybedtools.release_url = mock_release_url()) + on.exit({ + options(cybedtools.release_url = old_opt) + if (is.na(old_env)) Sys.unsetenv("CYBEDTOOLS_RELEASE_URL") else Sys.setenv(CYBEDTOOLS_RELEASE_URL = old_env) + }, add = TRUE) + force(code) +} + +fresh_cache_dir <- function() { + # Redirect the cache to a throwaway temp directory for the duration of the + # test, via the same option every real caller uses to find R_user_dir(): + # we can't override R_user_dir() itself, so instead we point HOME/ + # R_USER_CACHE_DIR-derived resolution isn't practical here; instead we + # simply let cybed_fetch() use the real user cache dir under a + # version-namespaced subfolder ("1.0.0"), which does not collide with any + # real release version, and clean it up afterward. + dir <- file.path(tools::R_user_dir("cybedtools", which = "cache"), "1.0.0") + withr::defer(unlink(dir, recursive = TRUE), envir = parent.frame()) + dir +} + +test_that("cybed_fetch downloads and hash-verifies every framework by default", { + skip_if_no_mock_release() + skip_if_not_installed("withr") + cache_dir <- fresh_cache_dir() + + with_mock_release({ + result <- cybed_fetch(version = "1.0.0") + }) + + expect_s3_class(result, "tbl_df") + expect_setequal(result$framework_slug, + c("fixture-wf1", "fixture-wf2", "fixture-ped1")) + expect_true(all(result$sha256_verified)) + expect_true(all(file.exists(result$path))) + expect_true(all(normalizePath(dirname(result$path)) == normalizePath(cache_dir))) +}) + +test_that("cybed_fetch(frameworks =) fetches only the named subset", { + skip_if_no_mock_release() + skip_if_not_installed("withr") + fresh_cache_dir() + + with_mock_release({ + result <- cybed_fetch(frameworks = "fixture-wf1", version = "1.0.0") + }) + + expect_equal(nrow(result), 1) + expect_equal(result$framework_slug, "fixture-wf1") +}) + +test_that("cybed_fetch errors on an unknown framework slug", { + skip_if_no_mock_release() + skip_if_not_installed("withr") + fresh_cache_dir() + + with_mock_release({ + expect_error( + cybed_fetch(frameworks = "not-a-real-framework", version = "1.0.0"), + class = "cybedtools_framework_not_found" + ) + }) +}) + +test_that("cybed_fetch aborts and deletes the file on a hash mismatch", { + skip_if_no_mock_release() + skip_if_not_installed("withr") + cache_dir <- fresh_cache_dir() + + # Point at a manifest whose declared hash cannot match the real file: copy + # the mock release to a temp dir and corrupt one entry's sha256. + tmp_release <- withr::local_tempdir() + tmp_version_dir <- file.path(tmp_release, "1.0.0") + dir.create(tmp_version_dir, recursive = TRUE) + file.copy( + list.files(file.path(mock_release_dir, "1.0.0"), full.names = TRUE), + tmp_version_dir + ) + manifest_path <- file.path(tmp_version_dir, "manifest.json") + manifest <- jsonlite::fromJSON(manifest_path, simplifyVector = FALSE) + manifest$files[[1]]$sha256 <- "0000000000000000000000000000000000000000000000000000000000000" + jsonlite::write_json(manifest, manifest_path, auto_unbox = TRUE, pretty = TRUE) + + tmp_url <- paste0("file:///", normalizePath(tmp_release, winslash = "/", mustWork = TRUE)) + old_opt <- getOption("cybedtools.release_url") + options(cybedtools.release_url = tmp_url) + on.exit(options(cybedtools.release_url = old_opt), add = TRUE) + + bad_slug <- manifest$files[[1]]$slug + + expect_error( + cybed_fetch(frameworks = bad_slug, version = "1.0.0"), + class = "cybedtools_hash_mismatch" + ) + expect_false(file.exists(file.path(cache_dir, paste0(bad_slug, ".nt.gz")))) +}) + +test_that("cybed_fetch does not re-download an already-verified cache entry", { + skip_if_no_mock_release() + skip_if_not_installed("withr") + cache_dir <- fresh_cache_dir() + + with_mock_release({ + first <- cybed_fetch(frameworks = "fixture-wf1", version = "1.0.0") + stamp1 <- file.info(first$path)$mtime + Sys.sleep(1.1) + second <- cybed_fetch(frameworks = "fixture-wf1", version = "1.0.0") + stamp2 <- file.info(second$path)$mtime + }) + + expect_equal(stamp1, stamp2) +}) + +test_that("cybed_fetch writes only under the user cache directory", { + skip_if_no_mock_release() + skip_if_not_installed("withr") + cache_dir <- fresh_cache_dir() + cache_root <- tools::R_user_dir("cybedtools", which = "cache") + + with_mock_release({ + result <- cybed_fetch(version = "1.0.0") + }) + + expect_true(all(startsWith(normalizePath(result$path), normalizePath(cache_root)))) +}) + +test_that("load_graph fetches and parses the requested frameworks into one graph", { + skip_if_no_mock_release() + skip_if_not_installed("withr") + fresh_cache_dir() + + with_mock_release({ + rdf <- load_graph(frameworks = c("fixture-wf1", "fixture-wf2"), version = "1.0.0") + }) + + meta <- framework_metadata(rdf) + expect_setequal(meta$framework_slug, c("fixture-wf1", "fixture-wf2")) +}) + +test_that("cybed_release_base_url honors the option over the environment variable and default", { + old_opt <- getOption("cybedtools.release_url") + old_env <- Sys.getenv("CYBEDTOOLS_RELEASE_URL", unset = NA_character_) + on.exit({ + options(cybedtools.release_url = old_opt) + if (is.na(old_env)) Sys.unsetenv("CYBEDTOOLS_RELEASE_URL") else Sys.setenv(CYBEDTOOLS_RELEASE_URL = old_env) + }, add = TRUE) + + Sys.setenv(CYBEDTOOLS_RELEASE_URL = "file:///env-only") + options(cybedtools.release_url = NULL) + expect_equal(cybedtools:::cybed_release_base_url(), "file:///env-only") + + options(cybedtools.release_url = "file:///option-wins") + expect_equal(cybedtools:::cybed_release_base_url(), "file:///option-wins") +}) + +test_that("the default base URL points at this repo's GitHub release assets", { + old_opt <- getOption("cybedtools.release_url") + old_env <- Sys.getenv("CYBEDTOOLS_RELEASE_URL", unset = NA_character_) + on.exit({ + options(cybedtools.release_url = old_opt) + if (is.na(old_env)) Sys.unsetenv("CYBEDTOOLS_RELEASE_URL") else Sys.setenv(CYBEDTOOLS_RELEASE_URL = old_env) + }, add = TRUE) + options(cybedtools.release_url = NULL) + Sys.unsetenv("CYBEDTOOLS_RELEASE_URL") + + expect_equal( + cybedtools:::cybed_release_base_url(), + "https://github.com/ryanstraight/cybedtools/releases/download/data-v{version}" + ) +}) + +test_that("cybed_release_url substitutes {version} in place for a GitHub release base, and appends a version segment for every other base", { + expect_equal( + cybedtools:::cybed_release_url( + "https://github.com/ryanstraight/cybedtools/releases/download/data-v{version}", + "2026.09.1", "manifest.json" + ), + "https://github.com/ryanstraight/cybedtools/releases/download/data-v2026.09.1/manifest.json" + ) + expect_equal( + cybedtools:::cybed_release_url("file:///mock-release", "1.0.0", "fixture-wf1.nt.gz"), + "file:///mock-release/1.0.0/fixture-wf1.nt.gz" + ) +}) diff --git a/tests/testthat/test-cybok-assembler.R b/tests/testthat/test-cybok-assembler.R new file mode 100644 index 0000000..4312659 --- /dev/null +++ b/tests/testthat/test-cybok-assembler.R @@ -0,0 +1,290 @@ +# Tests for the CyBOK ingest helpers (scripts/010-ingest-cybok.R) and JSON-LD +# adapter (scripts/_assemble-cybok.R). +# +# Both scripts guard their top-level pipeline run, so source()-ing them loads +# functions only. The fixtures below are synthetic: page words with +# coordinates, shaped like pdftools::pdf_data() output, a small drawn tree as +# a logical ink matrix, and small tables in the shape the ingest writes. No +# staged framework data is read, except by the last test, which skips when +# the staged tables are absent. +# +# scripts/ is .Rbuildignore'd, so these tests skip under R CMD check on the +# built tarball and run under devtools::test() in the source tree. + +source_cybok_scripts <- function() { + for (pkg in c("pdftools", "readr", "yaml", "digest", "stringr", "here")) { + testthat::skip_if_not_installed(pkg) + } + ingest <- testthat::test_path("..", "..", "scripts", "010-ingest-cybok.R") + adapter <- testthat::test_path("..", "..", "scripts", "_assemble-cybok.R") + if (!file.exists(ingest) || !file.exists(adapter)) { + testthat::skip("cybok scripts not available (built-tarball check)") + } + env <- new.env(parent = globalenv()) + suppressPackageStartupMessages(suppressWarnings(source(ingest, local = env))) + suppressPackageStartupMessages(suppressWarnings(source(adapter, local = env))) + env +} + +# One printed line as pdf_data() words: each word 30 points right of the last. +fixture_words <- function(x, y, text, height = 10) { + words <- strsplit(text, " ", fixed = TRUE)[[1]] + tibble::tibble(x = x + 30 * (seq_along(words) - 1), y = y, width = 25, + height = height, text = words) +} + +test_that("lines join on one space, and a line-break hyphen keeps its hyphen", { + env <- source_cybok_scripts() + expect_identical(env$cybok_join_lines(c("Risk Management &", " Governance")), + "Risk Management & Governance") + expect_identical(env$cybok_join_lines(c("ELEC-", "TRONIC")), "ELEC-TRONIC") + # The matching-only reading drops the hyphen instead. + expect_identical(env$cybok_join_lines(c("ELEC-", "TRONIC"), soft_hyphen = TRUE), + "ELECTRONIC") + expect_identical(env$cybok_normalise_text("a b\nc Accountabil-\n ity"), + "a b c Accountabil-ity") +}) + +test_that("Figure 2 parses into categories and multi-line KA names", { + env <- source_cybok_scripts() + words <- dplyr::bind_rows( + fixture_words(213, 71, "Human, Organisational and Regulatory Aspects", height = 7), + fixture_words(62, 79, "Risk Management &"), + fixture_words(62, 89, "Governance"), + fixture_words(168, 79, "Security management systems"), + fixture_words(62, 109, "Law & Regulation"), + fixture_words(168, 104, "International and national"), + fixture_words(213, 130, "Attacks and Defences", height = 7), + fixture_words(62, 140, "Forensics"), + fixture_words(168, 140, "The collection of evidence"), + fixture_words(200, 170, "Figure 2: Short descriptions") + ) + parsed <- env$cybok_parse_figure2(words) + expect_identical(parsed$categories$category_name, + c("Human, Organisational and Regulatory Aspects", "Attacks and Defences")) + expect_identical(parsed$kas$ka_name, + c("Risk Management & Governance", "Law & Regulation", "Forensics")) + expect_identical(parsed$kas$category_name, + c("Human, Organisational and Regulatory Aspects", + "Human, Organisational and Regulatory Aspects", + "Attacks and Defences")) +}) + +test_that("an A-to-Z page splits into top-aligned rows and drops section letters", { + env <- source_cybok_scripts() + words <- dplyr::bind_rows( + fixture_words(73, 72, "INDICATIVE MATERIAL"), + fixture_words(246, 72, "TOPIC"), + fixture_words(485, 73, "CyBOK KA", height = 7), + fixture_words(73, 90, "A", height = 11), + fixture_words(73, 104, "ACCESS CONTROL"), + fixture_words(246, 104, "AUTHORISATION"), + fixture_words(506, 105, "AAA", height = 7), + fixture_words(73, 117, "ADMISSION INTO ELEC-"), + fixture_words(73, 127, "TRONIC DOCUMENTS"), + fixture_words(246, 117, "DEMATERIALISATION OF ELEC-"), + fixture_words(246, 127, "TRONIC TRUST SERVICES"), + fixture_words(513, 118, "LR", height = 7) + ) + rows <- env$cybok_parse_atoz_page(words) + expect_identical(rows$indicative_material, + c("ACCESS CONTROL", "ADMISSION INTO ELEC-TRONIC DOCUMENTS")) + expect_identical(rows$topic, + c("AUTHORISATION", "DEMATERIALISATION OF ELEC-TRONIC TRUST SERVICES")) + expect_identical(rows$ka_acronym, c("AAA", "LR")) + expect_identical(rows$indicative_material_soft[2], "ADMISSION INTO ELECTRONIC DOCUMENTS") +}) + +# A drawn tree, one pixel per point: an unlabelled root at the far left, two +# Topics, three children and one grandchild, each label in a stroked box. +draw_line <- function(ink, x0, y0, x1, y1) { + n <- max(abs(x1 - x0), abs(y1 - y0)) + 1 + xs <- round(seq(x0, x1, length.out = n)) + ys <- round(seq(y0, y1, length.out = n)) + ink[cbind(xs, ys)] <- TRUE + ink +} +draw_box <- function(ink, l, r, t, b) { + ink[l:r, t] <- TRUE + ink[l:r, b] <- TRUE + ink[l, t:b] <- TRUE + ink[r, t:b] <- TRUE + ink +} +fixture_tree <- function() { + labels <- tibble::tibble( + node_id = 1:6, + text = c("topic one", "topic two", "child a", "child b", "child c", "grandchild"), + x0 = c(40, 40, 100, 100, 100, 150), + x1 = c(60, 60, 120, 120, 120, 170), + y = c(20, 60, 10, 30, 60, 10) + ) + ink <- matrix(FALSE, nrow = 200, ncol = 100) + for (i in seq_len(nrow(labels))) { + ink <- draw_box(ink, labels$x0[i] - 3, labels$x1[i] + 3, labels$y[i] - 3, labels$y[i] + 8) + } + ink <- draw_line(ink, 5, 42, 36, 22) + ink <- draw_line(ink, 5, 42, 36, 62) + ink <- draw_line(ink, 64, 22, 96, 12) + ink <- draw_line(ink, 64, 22, 96, 32) + ink <- draw_line(ink, 64, 62, 96, 62) + ink <- draw_line(ink, 124, 12, 146, 12) + list(labels = labels, ink = ink) +} + +test_that("Topics are the boxes on the root's fan, and their children hang from them", { + env <- source_cybok_scripts() + fx <- fixture_tree() + graph <- env$cybok_tree_links(fx$labels, fx$ink, s = 1, label_height = 4) + expect_length(graph$problems, 0L) + expect_identical(graph$nodes$text[graph$nodes$is_topic], c("topic one", "topic two")) + expect_identical(graph$links$topic, c(1L, 1L, 2L)) + expect_identical(graph$links$child, c(3L, 4L, 5L)) + tables <- env$cybok_tree_tables(list(nodes = graph$nodes, links = graph$links), "XX") + expect_identical(tables$topics$topic_id, c("XX-01", "XX-02")) + expect_identical(tables$im$term, c("child a", "child b", "child c")) + expect_identical(tables$im$topic_id, c("XX-01", "XX-01", "XX-02")) +}) + +test_that("a label with no drawn box stops the tree read", { + env <- source_cybok_scripts() + fx <- fixture_tree() + fx$labels <- dplyr::bind_rows(fx$labels, tibble::tibble( + node_id = 7L, text = "unboxed", x0 = 150, x1 = 170, y = 80)) + graph <- env$cybok_tree_links(fx$labels, fx$ink, s = 1, label_height = 4) + expect_match(graph$problems, "no box found around \"unboxed\"", fixed = TRUE) +}) + +test_that("A-to-Z rows resolve by folded case and report what does not resolve", { + env <- source_cybok_scripts() + topics <- tibble::tibble(ka_acronym = "RMG", topic_id = c("RMG-01", "RMG-02"), + ordinal = 1:2, title = c("risk definition", "risk governance")) + im <- tibble::tibble(ka_acronym = "RMG", topic_id = c("RMG-01", "RMG-02"), + ordinal = 1L, term = c("risk assessment", "security culture")) + atoz <- tibble::tibble( + indicative_material = c("RISK ASSESSMENT", "RISK ASSESSMENT", "SECURITY CULTURE", "NEW TERM"), + topic = c("RISK DEFINITION", "RISK DEFINITIONS", "RISK DEFINITION", "RISK GOVERNANCE"), + ka_acronym = "RMG", + indicative_material_soft = c("RISK ASSESSMENT", "RISK ASSESSMENT", "SECURITY CULTURE", "NEW TERM"), + topic_soft = c("RISK DEFINITION", "RISK DEFINITIONS", "RISK DEFINITION", "RISK GOVERNANCE") + ) + res <- env$cybok_resolve_atoz(atoz, topics, im) + expect_identical(res$status, c("resolved", + "topic worded differently, term in tree", + "term under a different topic in the tree", + "topic resolved, term not in tree")) + expect_identical(res$term_in_tree[1], "risk assessment") + expect_identical(res$term_topic_in_tree[3], "RMG-02") +}) + +test_that("crosswalk KA names resolve exactly, or with and read as an ampersand", { + env <- source_cybok_scripts() + kas <- tibble::tibble(ka_acronym = c("LR", "F"), ka_name = c("Law & Regulation", "Forensics")) + res <- env$cybok_resolve_crosswalk_kas(c("Forensics", "Law and Regulation", "Other"), kas) + expect_identical(res$ka_acronym, c("F", "LR", NA)) + expect_identical(res$resolution, c("exact", "and read as ampersand", "unresolved")) +}) + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +fixture_cybok_parts <- function(env) { + env$build_cybok_parts( + knowledge_areas = tibble::tibble( + ka_acronym = c("RMG", "F"), + ka_name = c("Risk Management & Governance", "Forensics"), + category_name = c("Human, Organisational and Regulatory Aspects", "Attacks and Defences"), + ka_order = c(1L, 2L), + version = c("1.1.1", "1.0.1") + ), + topics = tibble::tibble( + ka_acronym = c("RMG", "RMG", "F"), + topic_id = c("RMG-01", "RMG-02", "F-01"), + ordinal = c(1L, 2L, 1L), + title = c("risk definition", "risk governance", "definitions and conceptual models") + ), + indicative_material = tibble::tibble( + ka_acronym = c("RMG", "RMG", "RMG", "F"), + topic_id = c("RMG-01", "RMG-01", "RMG-02", "F-01"), + ordinal = c(1L, 2L, 1L, 1L), + term = c("risk assessment", "risk management", "governance models", "definitions") + ), + prov = list( + framework_version = "CyBOK v1.1.0", + framework_date = "2021-07", + publisher = "The National Cyber Security Centre (NCSC), United Kingdom", + licensing = list( + source_license = "Open Government Licence v3.0.", + attribution = "CyBOK © Crown Copyright, The National Cyber Security Centre 2021, licensed under the Open Government Licence: http://www.nationalarchives.gov.uk/doc/open-government-licence/." + ) + ) + ) +} + +node_by_id <- function(nodes, iri) { + hit <- Filter(\(n) identical(as.character(n[["@id"]]), iri), nodes) + if (length(hit) != 1L) stop("expected one node for ", iri, ", found ", length(hit)) + hit[[1]] +} + +test_that("the framework node carries the OGL attribution as credit text", { + env <- source_cybok_scripts() + parts <- fixture_cybok_parts(env) + fw <- parts$framework + expect_identical(as.character(fw[["@id"]]), "cybed:framework/cybok-v1.1.0") + expect_identical(fw[["cybed:jurisdiction"]], "UK") + expect_match(fw[["schema:creditText"]], "Open Government Licence", fixed = TRUE) + expect_identical(parts$prefix, "cybok") +}) + +test_that("Knowledge Areas are organizing units, never roles, with category and version", { + env <- source_cybok_scripts() + parts <- fixture_cybok_parts(env) + rmg <- node_by_id(parts$roles, "cybok:RMG") + expect_true(all(c("cybok:KnowledgeArea", "cybed:OrganizingUnit") %in% rmg[["@type"]])) + expect_false("cybed:Role" %in% rmg[["@type"]]) + expect_identical(rmg[["cybok:category"]], "Human, Organisational and Regulatory Aspects") + expect_identical(rmg[["schema:version"]], "1.1.1") + expect_identical(vapply(rmg[["cybed:hasElement"]], \(e) as.character(e[["@id"]]), character(1)), + c("cybok:RMG-01", "cybok:RMG-02", "cybok:RMG-01.sub.1", + "cybok:RMG-01.sub.2", "cybok:RMG-02.sub.1")) +}) + +test_that("Topics are elements and Indicative Material is a Subpoint of its Topic", { + env <- source_cybok_scripts() + parts <- fixture_cybok_parts(env) + expect_length(parts$elements, 7L) + topic <- node_by_id(parts$elements, "cybok:RMG-01") + expect_true(all(c("cybok:Topic", "cybed:RoleElement") %in% topic[["@type"]])) + expect_false("cybed:Subpoint" %in% topic[["@type"]]) + expect_identical(topic[["cybed:elementText"]], "risk definition") + im <- node_by_id(parts$elements, "cybok:RMG-01.sub.2") + expect_true(all(c("cybok:IndicativeMaterial", "cybed:Subpoint", "cybed:RoleElement") %in% + im[["@type"]])) + expect_false("cybok:Topic" %in% im[["@type"]]) + expect_identical(im[["cybed:elementText"]], "risk management") + expect_identical(as.character(im[["cybed:elaborates"]][["@id"]]), "cybok:RMG-01") + ids <- vapply(c(parts$roles, parts$elements), \(n) as.character(n[["@id"]]), character(1)) + expect_equal(anyDuplicated(ids), 0L) +}) + +test_that("the cybok prefix resolves to the cybed-minted namespace", { + ctx <- build_jsonld_context("cybok") + expect_identical(ctx[["cybok"]], "https://w3id.org/cybed/framework/cybok#") +}) + +test_that("the staged tables hold 21 KAs and pass the verbatim check", { + tables <- testthat::test_path("..", "..", "data", "raw", "cybok", "tables") + skip_if_not(file.exists(file.path(tables, "knowledge-areas.csv")), + "CyBOK staged tables not available.") + kas <- readr::read_csv(file.path(tables, "knowledge-areas.csv"), show_col_types = FALSE) + expect_equal(nrow(kas), 21L) + expect_equal(length(unique(kas$category_name)), 5L) + check <- readr::read_csv(file.path(tables, "verbatim-check.csv"), show_col_types = FALSE) + expect_true(nrow(check) > 0) + expect_true(all(check$found)) + im <- readr::read_csv(file.path(tables, "indicative-material.csv"), show_col_types = FALSE) + topics <- readr::read_csv(file.path(tables, "topics.csv"), show_col_types = FALSE) + expect_true(all(im$topic_id %in% topics$topic_id)) +}) diff --git a/tests/testthat/test-demo-graph.R b/tests/testthat/test-demo-graph.R index 52a1175..227abb8 100644 --- a/tests/testthat/test-demo-graph.R +++ b/tests/testthat/test-demo-graph.R @@ -39,7 +39,7 @@ test_that("make_demo_graph yields the documented v0.2.0 structure", { # 6 cybed:hasElement triples: 5 original + 1 Subpoint added as a # cluster child. The Example is deliberately NOT in cybed:hasElement. - reb <- role_element_bindings(rdf) + reb <- unit_element_bindings(rdf) expect_equal(nrow(reb), 6) expect_false(any(grepl("\\.example\\.\\d+$", reb$element))) }) diff --git a/tests/testthat/test-framework-licenses.R b/tests/testthat/test-framework-licenses.R index a395d07..9e5e104 100644 --- a/tests/testthat/test-framework-licenses.R +++ b/tests/testthat/test-framework-licenses.R @@ -45,7 +45,7 @@ test_that("public_redistribution uses the declared vocabulary", { test_that("public_redistribution agrees with docs/framework-invariants.yml", { # Hard-coded from docs/framework-invariants.yml, which is the declaring # document. A framework carrying no `public_redistribution` key there means - # "unrestricted"; only nine frameworks declare one. The test suite does not + # "unrestricted"; only twelve frameworks declare one. The test suite does not # otherwise read that YAML, so the map is pinned here rather than parsed. # If the YAML changes, this map changes with it. expected <- c( @@ -55,11 +55,14 @@ test_that("public_redistribution agrees with docs/framework-invariants.yml", { "sfia-9" = "local_only", "cyberorg-k12-v1.0" = "full_with_attribution", "csta-2017" = "full_with_attribution", + "csta-2026" = "full_with_attribution", "csec2017-v1" = "structure_only", - "digcomp-2.2" = "full_with_attribution", + "digcomp-3.0" = "full_with_attribution", "cyqual-v1.2.0" = "full_with_attribution", - "ccssf-2022" = "structure_only", - "otccf-v1.1" = "structure_only" + "ccssf-2022" = "full_with_attribution", + "otccf-v1.1" = "structure_only", + "scywf-1.5" = "full_with_attribution", + "cybok-v1.1.0" = "full_with_attribution" ) fw <- framework_licenses[framework_licenses$layer == "framework", ] @@ -90,7 +93,7 @@ test_that("only attribution may be NA, and granted is logical", { test_that("granted is TRUE only where a steward gave written permission", { expect_setequal( framework_licenses$slug[framework_licenses$granted], - c("cyqual-v1.2.0", "otccf-v1.1") + c("cyqual-v1.2.0", "otccf-v1.1", "scywf-1.5", "ccssf-2022") ) }) @@ -102,19 +105,23 @@ test_that("framework_summary's license column is the join of license_short", { ) }) -test_that("the CCSSF row never claims permission", { - # Decided 2026-09-19: the steward said only that the material is - # copyrighted under the Government of Canada and should be referenced when - # used. Public text must not characterise that as permission until the - # steward confirms. +test_that("the CCSSF row claims permission, granted 2026-09-21", { + # Owner decision 2026-09-21: the Canadian Centre for Cyber Security gave + # cybedtools written permission for full-text publication with + # attribution, superseding the 2026-09-19 decision (the steward's earlier + # reply asked only that its material be referenced, which was not itself + # an affirmative grant). ccssf <- framework_licenses[framework_licenses$slug == "ccssf-2022", ] expect_equal(nrow(ccssf), 1L) - expect_false(ccssf$granted) - expect_false( + expect_true(ccssf$granted) + expect_true( any(grepl("permission", unlist(ccssf), ignore.case = TRUE)) ) - expect_equal(ccssf$license_short, "Government of Canada copyright") - expect_equal(ccssf$public_redistribution, "structure_only") + expect_false( + any(grepl("referenced as.*asked", unlist(ccssf), ignore.case = TRUE)) + ) + expect_equal(ccssf$license_short, "Government of Canada, with permission") + expect_equal(ccssf$public_redistribution, "full_with_attribution") }) test_that("the OTCCF row carries CSA's prescribed attribution verbatim", { @@ -151,3 +158,38 @@ test_that("cybed_license returns the whole tibble, one row, or a classed error", class = "cybedtools_scalar_input" ) }) + +test_that("the csta-2026 row cites the document's licence page and DOI", { + row <- cybed_license("csta-2026") + expect_equal(row$license_short, "CC BY-NC-SA 4.0") + expect_equal(row$public_redistribution, "full_with_attribution") + expect_false(row$granted) + expect_match(row$license, "p. iv", fixed = TRUE) + expect_match(row$attribution, "Computer Science Teachers Association", fixed = TRUE) + expect_match(row$attribution, "10.1145/3820482", fixed = TRUE) +}) + +test_that("the scywf row names NCA, links the official page and records the grant", { + row <- cybed_license("scywf-1.5") + expect_equal(row$public_redistribution, "full_with_attribution") + expect_true(row$granted) + expect_equal(row$terms_url, "https://nca.gov.sa/en/pages/scywf.html") + expect_match(row$attribution, "National Cybersecurity Authority (NCA)", fixed = TRUE) + expect_match(row$attribution, "https://nca.gov.sa/en/pages/scywf.html", fixed = TRUE) + expect_match(row$attribution, "SCyWF – 1.5: 2026", fixed = TRUE) + expect_match(row$license, "verbatim", fixed = TRUE) + expect_match(row$license, "derived", fixed = TRUE) +}) + +test_that("the cybok row carries the OGL and CyBOK's prescribed attribution", { + row <- cybed_license("cybok-v1.1.0") + expect_equal(row$public_redistribution, "full_with_attribution") + expect_false(row$granted) + expect_equal(row$license_short, "OGL v3.0") + expect_identical( + row$attribution, + "CyBOK © Crown Copyright, The National Cyber Security Centre 2021, licensed under the Open Government Licence: http://www.nationalarchives.gov.uk/doc/open-government-licence/." + ) + expect_match(row$license, "Open Government Licence v3.0", fixed = TRUE) + expect_match(row$terms_url, "nationalarchives.gov.uk/doc/open-government-licence", fixed = TRUE) +}) diff --git a/tests/testthat/test-framework-summary.R b/tests/testthat/test-framework-summary.R index ea90807..6dc2d0f 100644 --- a/tests/testthat/test-framework-summary.R +++ b/tests/testthat/test-framework-summary.R @@ -7,7 +7,7 @@ test_that("framework_summary has the expected shape", { expect_s3_class(framework_summary, "tbl_df") - expect_equal(nrow(framework_summary), 11L) + expect_equal(nrow(framework_summary), 14L) expect_named( framework_summary, c("framework_slug", "framework_name", "framework_type", "jurisdiction", @@ -19,13 +19,13 @@ test_that("framework_summary has the expected shape", { ) }) -test_that("framework slugs are unique and cover all eleven frameworks", { +test_that("framework slugs are unique and cover all fourteen frameworks", { expect_equal(anyDuplicated(framework_summary$framework_slug), 0L) expect_setequal( framework_summary$framework_slug, c("nice-v2", "dcwf-v5.1", "ecsf-v1", "sfia-9", "cyberorg-k12-v1.0", - "csta-2017", "csec2017-v1", "digcomp-2.2", "cyqual-v1.2.0", - "ccssf-2022", "otccf-v1.1") + "csta-2017", "csec2017-v1", "digcomp-3.0", "cyqual-v1.2.0", + "ccssf-2022", "otccf-v1.1", "csta-2026", "scywf-1.5", "cybok-v1.1.0") ) }) @@ -53,7 +53,8 @@ test_that("role_count is NA exactly for the frameworks that assert no roles", { na_slugs <- framework_summary$framework_slug[is.na(framework_summary$role_count)] expect_setequal( na_slugs, - c("sfia-9", "cyberorg-k12-v1.0", "csta-2017", "csec2017-v1", "digcomp-2.2") + c("sfia-9", "cyberorg-k12-v1.0", "csta-2017", "csec2017-v1", "digcomp-3.0", + "csta-2026", "cybok-v1.1.0") ) # The role-dependent columns are NA together. expect_equal(is.na(framework_summary$elements_per_role_strict), @@ -84,28 +85,30 @@ test_that("OTCCF is the only framework publishing unit relations, and it has 310 expect_true(all(others$unit_relation_count == 0L)) }) -test_that("the original eight frameworks' pre-existing values are unchanged", { +test_that("seven of the original eight frameworks' pre-existing values are unchanged", { # Pinned from the v0.2.0 shipped object. These are published figures. + # DigComp is excluded here: 3.0 replaced 2.2 in place (2026-09-21), a + # deliberate content change, not a regression. See the dedicated + # "DigComp 3.0" test below for its pinned values. pinned <- tibble::tibble( framework_slug = c("nice-v2", "dcwf-v5.1", "ecsf-v1", "sfia-9", - "cyberorg-k12-v1.0", "csta-2017", "csec2017-v1", - "digcomp-2.2"), - organizing_unit_count = c(53L, 74L, 12L, 147L, 116L, 25L, 8L, 5L), + "cyberorg-k12-v1.0", "csta-2017", "csec2017-v1"), + organizing_unit_count = c(53L, 74L, 12L, 147L, 116L, 25L, 8L), element_count_strict = - c(2211L, 2945L, 374L, 672L, 123L, 120L, 38L, 21L), - subpoint_count = c(14L, 1107L, 16L, 149L, 0L, 24L, 2L, 0L), - example_count = c(0L, 0L, 0L, 0L, 369L, 114L, 0L, 0L), + c(2211L, 2945L, 374L, 672L, 123L, 120L, 38L), + subpoint_count = c(14L, 1107L, 16L, 149L, 0L, 24L, 2L), + example_count = c(0L, 0L, 0L, 0L, 369L, 114L, 0L), element_count_with_examples = - c(2225L, 4052L, 390L, 821L, 492L, 258L, 40L, 21L), + c(2225L, 4052L, 390L, 821L, 492L, 258L, 40L), elements_per_organizing_unit_strict = - c(41.7, 39.8, 31.2, 4.6, 1.1, 4.8, 4.8, 4.2), + c(41.7, 39.8, 31.2, 4.6, 1.1, 4.8, 4.8), elements_per_organizing_unit_with_examples = - c(42.0, 54.8, 32.5, 5.6, 4.2, 10.3, 5.0, 4.2) + c(42.0, 54.8, 32.5, 5.6, 4.2, 10.3, 5.0) ) got <- framework_summary[match(pinned$framework_slug, framework_summary$framework_slug), ] - expect_equal(nrow(got), 8L) + expect_equal(nrow(got), 7L) for (cl in setdiff(names(pinned), "framework_slug")) { expect_equal(got[[cl]], pinned[[cl]], info = cl) @@ -114,17 +117,17 @@ test_that("the original eight frameworks' pre-existing values are unchanged", { expect_equal( got$framework_type, c("workforce", "workforce", "workforce", "workforce", - "pedagogy", "pedagogy", "pedagogy", "pedagogy") + "pedagogy", "pedagogy", "pedagogy") ) expect_equal( got$jurisdiction, - c("US", "US", "EU", "global", "US", "US", "global", "EU") + c("US", "US", "EU", "global", "US", "US", "global") ) }) -test_that("role densities are pinned for the six role-asserting frameworks", { +test_that("role densities are pinned for the six v0.3.0 role-asserting frameworks", { role_rows <- framework_summary[!is.na(framework_summary$role_count), ] - expect_equal(nrow(role_rows), 6L) + expect_equal(nrow(role_rows), 7L) expect_equal( role_rows$role_count[match( @@ -148,3 +151,55 @@ test_that("role densities are pinned for the six role-asserting frameworks", { c(44.7, 54.8, 32.5, 30.9, 22.8, 18.0) ) }) + +test_that("the csta-2026 row carries its measured counts", { + row <- framework_summary[framework_summary$framework_slug == "csta-2026", ] + expect_equal(nrow(row), 1L) + expect_equal(row$framework_type, "pedagogy") + expect_equal(row$jurisdiction, "US") + expect_equal(row$organizing_unit_count, 53L) + expect_true(is.na(row$role_count)) + expect_equal(row$element_count_strict, 331L) + expect_equal(row$example_count, 652L) + expect_equal(row$element_count_with_examples, + row$element_count_strict + row$subpoint_count + row$example_count) + expect_equal(row$license, "CC BY-NC-SA 4.0") +}) + +test_that("the scywf row carries its measured counts", { + row <- framework_summary[framework_summary$framework_slug == "scywf-1.5", ] + expect_equal(nrow(row), 1L) + expect_equal(row$framework_name, "SCyWF 1.5") + expect_equal(row$framework_type, "workforce") + expect_equal(row$jurisdiction, "SA") + # 40 job roles + 24 competency areas + 12 specialty areas + 5 categories. + expect_equal(row$organizing_unit_count, 81L) + expect_equal(row$role_count, 40L) + expect_equal(row$element_count_strict, 1419L) + expect_equal(row$subpoint_count, 0L) + expect_equal(row$example_count, 0L) + expect_equal(row$element_count_with_examples, 1419L) + expect_equal(row$unit_relation_count, 0L) + expect_equal(row$elements_per_organizing_unit_strict, 17.5) + # 1,418 distinct statements reach a role. S1505 is on no role card. + expect_equal(row$elements_per_role_strict, 35.5) + expect_equal(row$license, "NCA written permission") +}) + +test_that("the cybok row carries its measured counts", { + row <- framework_summary[framework_summary$framework_slug == "cybok-v1.1.0", ] + expect_equal(nrow(row), 1L) + expect_equal(row$framework_name, "CyBOK v1.1.0") + expect_equal(row$framework_type, "pedagogy") + expect_equal(row$jurisdiction, "UK") + # 21 Knowledge Areas. CyBOK asserts no roles. + expect_equal(row$organizing_unit_count, 21L) + expect_true(is.na(row$role_count)) + # 119 Topics, with the 477 Indicative Material nodes as Subpoints. + expect_equal(row$element_count_strict, 119L) + expect_equal(row$subpoint_count, 477L) + expect_equal(row$example_count, 0L) + expect_equal(row$element_count_with_examples, 596L) + expect_equal(row$unit_relation_count, 0L) + expect_equal(row$license, "OGL v3.0") +}) diff --git a/tests/testthat/test-release-export.R b/tests/testthat/test-release-export.R index 6a42586..ba3122d 100644 --- a/tests/testthat/test-release-export.R +++ b/tests/testthat/test-release-export.R @@ -502,6 +502,23 @@ test_that("a licence row is found by exact slug and by version suffix", { "cybedtools") }) +test_that("a framework named as its own slug is never a version of another", { + env <- source_release_common() + licenses <- data.frame(slug = c("csta-2017", "csta-2026"), + stringsAsFactors = FALSE) + # Without knowing csta-2026 is a framework of its own, the lookup cannot + # choose between the two and refuses. + expect_error( + env$release_license_row(licenses, "csta"), + class = "cybedtools_release_license_missing" + ) + reserved <- c("csta", "csta-2026") + expect_identical(env$release_license_row(licenses, "csta", reserved)$slug, + "csta-2017") + expect_identical(env$release_license_row(licenses, "csta-2026", reserved)$slug, + "csta-2026") +}) + test_that("a slug missing from framework_licenses aborts", { env <- source_release_common() licenses <- data.frame(slug = c("nice-v2", "otccf-v1.1"), @@ -517,9 +534,14 @@ test_that("every shipped slug resolves in the real licence table", { config_path <- testthat::test_path("..", "..", "docs", "data-release.yml") skip_if(!file.exists(config_path), "Release config not available.") + invariants_path <- testthat::test_path("..", "..", "docs", "framework-invariants.yml") + skip_if(!file.exists(invariants_path), "Invariants file not available.") + config <- yaml::read_yaml(config_path) + reserved <- names(yaml::read_yaml(invariants_path)$frameworks) for (slug in as.character(config$shipped)) { - row <- env$release_license_row(cybedtools::framework_licenses, slug) + row <- env$release_license_row(cybedtools::framework_licenses, slug, + reserved = reserved) expect_equal(nrow(row), 1L) } }) diff --git a/tests/testthat/test-scywf-assembler.R b/tests/testthat/test-scywf-assembler.R new file mode 100644 index 0000000..ad85921 --- /dev/null +++ b/tests/testthat/test-scywf-assembler.R @@ -0,0 +1,329 @@ +# Tests for the SCyWF ingest helpers (scripts/010-ingest-scywf.R) and JSON-LD +# adapter (scripts/_assemble-scywf.R). +# +# Both scripts guard their top-level pipeline run, so source()-ing them loads +# functions only. The fixtures below are synthetic: page words with +# coordinates, shaped like pdftools::pdf_data() output, and small tables in +# the shape the ingest writes. No staged framework data is read, except by the +# last test, which skips when the staged tables are absent. +# +# scripts/ is .Rbuildignore'd, so these tests skip under R CMD check on the +# built tarball and run under devtools::test() in the source tree. + +source_scywf_scripts <- function() { + for (pkg in c("pdftools", "readr", "yaml", "digest", "stringr", "here", "tidyr")) { + testthat::skip_if_not_installed(pkg) + } + ingest <- testthat::test_path("..", "..", "scripts", "010-ingest-scywf.R") + adapter <- testthat::test_path("..", "..", "scripts", "_assemble-scywf.R") + if (!file.exists(ingest) || !file.exists(adapter)) { + testthat::skip("scywf scripts not available (built-tarball check)") + } + env <- new.env(parent = globalenv()) + suppressPackageStartupMessages(suppressWarnings(source(ingest, local = env))) + suppressPackageStartupMessages(suppressWarnings(source(adapter, local = env))) + env +} + +# One printed line as pdf_data() words: each word 30 points right of the last. +fixture_line <- function(x, y, text) { + words <- strsplit(text, " ", fixed = TRUE)[[1]] + tibble::tibble(x = x + 30 * (seq_along(words) - 1), y = y, width = 25, + text = words) +} + +# One job role card, laid out as Appendix A lays it out: a label column at +# x = 85 and a value column at x = 161, each label centred on its cell. The +# description runs to two lines around its label, and the Competency Areas +# label carries the asterisk that points at a footnote under the card. +fixture_card_page <- function() { + dplyr::bind_rows( + fixture_line(273, 80, "Job Role Details"), + fixture_line(85, 100, "Job Role Name"), + fixture_line(161, 100, "Threat Hunter"), + fixture_line(85, 116, "Job Role ID"), + fixture_line(161, 116, "PD-TM-002"), + fixture_line(85, 132, "Category"), + fixture_line(161, 132, "Protection and Defense"), + fixture_line(85, 148, "Specialty Area"), + fixture_line(161, 148, "Threat Management"), + fixture_line(161, 164, "Proactively searches for undetected threats"), + fixture_line(85, 171, "Job Role Description"), + fixture_line(161, 178, "and recommends mitigation plans"), + fixture_line(85, 194, "Competency Areas*"), + fixture_line(161, 194, "Cyber Threat Intelligence (CA019)"), + fixture_line(85, 210, "Tasks"), + fixture_line(161, 210, "T0009, T0057"), + fixture_line(85, 226, "Knowledge"), + fixture_line(161, 226, "K0001, K0044"), + fixture_line(85, 242, "Skills"), + fixture_line(161, 242, "S0001"), + fixture_line(100, 300, "In addition to the areas they manage"), + fixture_line(90, 302, "*") + ) +} + +test_that("cell lines join on one space, and a line-break hyphen keeps its hyphen", { + env <- source_scywf_scripts() + plain <- env$scywf_join_lines(c("Designs and ", " oversees systems")) + expect_identical(plain$text, "Designs and oversees systems") + expect_equal(nrow(plain$joins), 0L) + + hyph <- env$scywf_join_lines(c("throughout the life-", "cycle.")) + expect_identical(hyph$text, "throughout the life-cycle.") + expect_equal(nrow(hyph$joins), 1L) + expect_identical(hyph$joins$left, "life-") + + expect_identical(env$scywf_normalise_text("a b\r\nc life-\n cycle"), + "a b c life-cycle") +}) + +test_that("row partition keeps rows contiguous and centres lines on anchors", { + env <- source_scywf_scripts() + # A three-line row centred on 114, then a one-line row. + expect_identical(env$scywf_partition_rows(c(100, 114, 128, 150), c(114, 150)), + c(1L, 1L, 1L, 2L)) + # Input order does not matter. The result follows the input. + expect_identical(env$scywf_partition_rows(c(40, 10, 20), c(15, 40)), + c(2L, 1L, 1L)) + expect_error(env$scywf_partition_rows(c(10), c(10, 20)), "Cannot partition") +}) + +test_that("a role card parses into verbatim fields, codes and its footnote", { + env <- source_scywf_scripts() + parsed <- env$scywf_parse_cards(list(fixture_card_page())) + role <- parsed$roles + expect_equal(nrow(role), 1L) + expect_identical(role$role_id, "PD-TM-002") + expect_identical(role$role_name, "Threat Hunter") + expect_identical(role$category_text, "Protection and Defense") + expect_identical(role$specialty_area_text, "Threat Management") + expect_identical(role$description, + "Proactively searches for undetected threats and recommends mitigation plans") + expect_identical(role$competency_areas_text, "Cyber Threat Intelligence (CA019)") + expect_identical(role$competency_areas_note, "In addition to the areas they manage") + + codes <- parsed$codes + expect_identical(codes$statement_id, c("T0009", "T0057", "K0001", "K0044", "S0001")) + expect_identical(codes$statement_type, + c("task", "task", "knowledge", "knowledge", "skill")) + expect_identical(env$scywf_role_competency_areas(role)$ca_id, "CA019") +}) + +test_that("a card whose label column does not read as a card stops the parse", { + env <- source_scywf_scripts() + page <- fixture_card_page() + page$text[page$text == "Knowledge"] <- "Abilities" + expect_error(env$scywf_parse_cards(list(page)), "expected sequence") +}) + +test_that("a skills page assigns multi-line text and the Technical? flag by row", { + env <- source_scywf_scripts() + words <- dplyr::bind_rows( + fixture_line(98, 100, "S0001"), + fixture_line(141, 100, "Skill in conducting scans"), + fixture_line(484, 100, "Yes"), + fixture_line(141, 114, "Skill in solving"), + fixture_line(98, 121, "S0017"), + fixture_line(484, 121, "No"), + fixture_line(141, 128, "problems") + ) + parsed <- env$scywf_parse_statement_page(words, "skill")$rows + expect_identical(parsed$statement_id, c("S0001", "S0017")) + expect_identical(parsed$text, c("Skill in conducting scans", "Skill in solving problems")) + expect_identical(parsed$technical, c("Yes", "No")) +}) + +test_that("the verbatim check finds carried text and flags text that is not there", { + env <- source_scywf_scripts() + roles <- tibble::tibble(role_id = "PD-TM-002", role_name = "Threat Hunter", + description = "Searches for threats.", + category_text = "Protection and Defense", + specialty_area_text = "Threat Management", + competency_areas_text = "Cyber Threat Intelligence (CA019)", + competency_areas_note = NA_character_) + statements <- tibble::tibble(statement_id = c("T0009", "S0001"), + statement_type = c("task", "skill"), + text = c("Correlate incident data", "Skill in scanning"), + technical = c(NA, "Yes")) + codes <- tibble::tibble(role_id = "PD-TM-002", statement_id = c("T0009", "S0001"), + statement_type = c("task", "skill"), ordinal = 1L) + competency <- tibble::tibble(ca_id = "CA019", ca_name = "Cyber Threat Intelligence", + description = "This Competency Area describes threats") + categories <- tibble::tibble(category_id = "PD", category_name = "Protection and Defense") + specialty <- tibble::tibble(specialty_area_id = "TM", specialty_area_name = "Threat Management", + category_id = "PD") + reference <- paste( + "Job Role Name Threat Hunter Job Role ID PD-TM-002 Category Protection and Defense", + "Specialty Area Threat Management Job Role Description Searches for", + "threats. Competency Areas Cyber Threat Intelligence (CA019) Tasks T0009", + "Skills S0001", "T0009 Correlate incident data", + "S0001 Skill in scan-\nning Yes", + "CA019 Cyber Threat Intelligence This Competency Area describes threats", + sep = "\n" + ) + check <- env$scywf_verbatim_check(roles, statements, codes, competency, + categories, specialty, reference) + failed <- check[!check$found, ] + # The reference spells the skill "scan-ning" across a break, which the + # hyphenation rule joins as "scan-ning". The carried text says "scanning", + # so both the plain and the paired rows for S0001 must fail. + expect_setequal(paste(failed$field, failed$id), + c("statement_text S0001", "statement_with_code S0001")) +}) + +# --------------------------------------------------------------------------- +# Adapter +# --------------------------------------------------------------------------- + +fixture_scywf_parts <- function(env) { + env$build_scywf_parts( + categories = tibble::tibble( + category_id = c("PD", "ICSOT"), + category_name = c("Protection and Defense", + "Industrial Control Systems and Operational Technologies (ICS/OT)") + ), + specialty_areas = tibble::tibble( + specialty_area_id = c("TM", "ICSOT"), + specialty_area_name = c("Threat Management", + "Industrial Control Systems and Operational Technologies (ICS/OT)"), + category_id = c("PD", "ICSOT") + ), + roles = tibble::tibble( + role_id = c("PD-TM-002", "ICSOT-ICSOT-005"), + role_name = c("Threat Hunter", "ICS/OT Cybersecurity Incident Responder"), + specialty_area_id = c("TM", "ICSOT"), + description = c("Proactively searches for undetected threats.", + "Investigates incidents within ICS/OT environments."), + competency_areas_text = c("Cyber Threat Intelligence (CA019)", + "Incident Management (CA011)"), + competency_areas_note = c(NA, "In addition to the areas they manage") + ), + statements = tibble::tibble( + statement_id = c("T0009", "K0001", "S0001"), + statement_type = c("task", "knowledge", "skill"), + text = c("Correlate incident data", + "Knowledge of computer networking concepts and protocols, and network security methodologies", + "Skill in conducting vulnerability scans"), + technical = c(NA, NA, "Yes") + ), + role_statements = tibble::tibble( + role_id = c("PD-TM-002", "PD-TM-002", "PD-TM-002", "ICSOT-ICSOT-005"), + statement_id = c("S0001", "T0009", "K0001", "T0009"), + statement_type = c("skill", "task", "knowledge", "task"), + ordinal = c(1L, 1L, 1L, 1L) + ), + competency_areas = tibble::tibble( + ca_id = c("CA011", "CA019"), + ca_name = c("Incident Management", "Cyber Threat Intelligence"), + description = c("This Competency Area describes incidents", + "This Competency Area describes threats") + ), + role_competency_areas = tibble::tibble( + role_id = c("PD-TM-002", "ICSOT-ICSOT-005"), + ca_id = c("CA019", "CA011"), + ordinal = c(1L, 1L) + ), + prov = list( + framework_version = "SCyWF – 1.5: 2026", + framework_date = "2026", + publisher = "National Cybersecurity Authority (NCA), Kingdom of Saudi Arabia", + licensing = list( + source_license = "Used with written permission of the National Cybersecurity Authority (NCA).", + attribution = "Issued by the National Cybersecurity Authority (NCA), Kingdom of Saudi Arabia. Source: https://nca.gov.sa/en/pages/scywf.html" + ) + ) + ) +} + +node_by_id <- function(nodes, iri) { + hit <- Filter(\(n) identical(as.character(n[["@id"]]), iri), nodes) + if (length(hit) != 1L) stop("expected one node for ", iri, ", found ", length(hit)) + hit[[1]] +} + +test_that("the framework node carries NCA as publisher and its attribution", { + env <- source_scywf_scripts() + parts <- fixture_scywf_parts(env) + fw <- parts$framework + expect_identical(as.character(fw[["@id"]]), "cybed:framework/scywf-1.5") + expect_identical(fw[["schema:version"]], "SCyWF – 1.5: 2026") + expect_identical(fw[["cybed:jurisdiction"]], "SA") + expect_match(fw[["schema:publisher"]], "National Cybersecurity Authority", fixed = TRUE) + expect_match(fw[["schema:creditText"]], "https://nca.gov.sa/en/pages/scywf.html", fixed = TRUE) + expect_identical(parts$prefix, "scywf") +}) + +test_that("statements keep their printed codes and text, and skills keep Technical?", { + env <- source_scywf_scripts() + parts <- fixture_scywf_parts(env) + expect_length(parts$elements, 3L) + s1 <- node_by_id(parts$elements, "scywf:S0001") + expect_true(all(c("scywf:SkillStatement", "cybed:RoleElement") %in% s1[["@type"]])) + expect_identical(s1[["cybed:elementText"]], "Skill in conducting vulnerability scans") + expect_identical(s1[["scywf:technical"]], "Yes") + t1 <- node_by_id(parts$elements, "scywf:T0009") + expect_true("scywf:TaskStatement" %in% t1[["@type"]]) + expect_null(t1[["scywf:technical"]]) + # The parser is off: a statement with a comma list is not split. + k1 <- node_by_id(parts$elements, "scywf:K0001") + expect_match(k1[["cybed:elementText"]], "protocols, and network security", fixed = TRUE) + expect_false(any(vapply(parts$elements, + \(n) "cybed:Subpoint" %in% n[["@type"]], logical(1)))) +}) + +test_that("job roles are cybed:Role, link card codes and carry derived edges apart", { + env <- source_scywf_scripts() + parts <- fixture_scywf_parts(env) + role <- node_by_id(parts$roles, "scywf:PD-TM-002") + expect_true(all(c("scywf:JobRole", "cybed:Role", "cybed:OrganizingUnit") %in% role[["@type"]])) + # Card order: tasks, then knowledge, then skills. + expect_identical(vapply(role[["cybed:hasElement"]], \(e) as.character(e[["@id"]]), character(1)), + c("scywf:T0009", "scywf:K0001", "scywf:S0001")) + expect_identical(role[["scywf:inSpecialtyArea"]][["@id"]], "scywf:specialty-TM") + expect_identical(vapply(role[["cybed:relatedUnit"]], \(e) e[["@id"]], character(1)), + "scywf:CA019") + expect_identical(role[["scywf:competencyAreasText"]], "Cyber Threat Intelligence (CA019)") + expect_null(role[["scywf:competencyAreasNote"]]) + other <- node_by_id(parts$roles, "scywf:ICSOT-ICSOT-005") + expect_identical(other[["scywf:competencyAreasNote"]], "In addition to the areas they manage") +}) + +test_that("the ICSOT category and specialty area stay two nodes", { + env <- source_scywf_scripts() + parts <- fixture_scywf_parts(env) + spec <- node_by_id(parts$roles, "scywf:specialty-ICSOT") + cat_node <- node_by_id(parts$roles, "scywf:category-ICSOT") + expect_identical(spec[["schema:identifier"]], "ICSOT") + expect_identical(cat_node[["schema:identifier"]], "ICSOT") + expect_true("scywf:SpecialtyArea" %in% spec[["@type"]]) + expect_true("scywf:Category" %in% cat_node[["@type"]]) + expect_identical(spec[["scywf:inCategory"]][["@id"]], "scywf:category-ICSOT") + for (n in list(spec, cat_node)) { + expect_false("cybed:Role" %in% n[["@type"]]) + expect_null(n[["cybed:hasElement"]]) + } + ca <- node_by_id(parts$roles, "scywf:CA019") + expect_true(all(c("scywf:CompetencyArea", "cybed:OrganizingUnit") %in% ca[["@type"]])) + expect_false("cybed:Role" %in% ca[["@type"]]) + + ids <- vapply(c(parts$roles, parts$elements), \(n) as.character(n[["@id"]]), character(1)) + expect_equal(anyDuplicated(ids), 0L) +}) + +test_that("the scywf prefix resolves to the cybed-minted namespace", { + ctx <- build_jsonld_context("scywf") + expect_identical(ctx[["scywf"]], "https://w3id.org/cybed/framework/scywf#") +}) + +test_that("the staged tables hold 40 roles and pass the verbatim check", { + tables <- testthat::test_path("..", "..", "data", "raw", "scywf", "tables") + skip_if_not(dir.exists(tables), "SCyWF staged tables not available.") + roles <- readr::read_csv(file.path(tables, "roles.csv"), show_col_types = FALSE) + expect_equal(nrow(roles), 40L) + check <- readr::read_csv(file.path(tables, "verbatim-check.csv"), show_col_types = FALSE) + expect_true(nrow(check) > 0) + expect_true(all(check$found)) + unresolved <- readr::read_csv(file.path(tables, "unresolved-codes.csv"), show_col_types = FALSE) + expect_equal(nrow(unresolved), 0L) +}) diff --git a/tests/testthat/test-sparql-helpers.R b/tests/testthat/test-sparql-helpers.R index 738d486..36f02dd 100644 --- a/tests/testthat/test-sparql-helpers.R +++ b/tests/testthat/test-sparql-helpers.R @@ -50,7 +50,8 @@ test_that("framework_metadata yields one row per framework with all metadata", { expect_s3_class(result, "tbl_df") expect_named(result, - c("framework", "name", "jurisdiction", "sector", "specificity")) + c("framework", "name", "jurisdiction", "sector", "specificity", + "framework_slug")) expect_equal(nrow(result), 2) fw_a <- result[result$jurisdiction == "US", ] @@ -88,7 +89,8 @@ test_that("role_framework_bindings returns one row per bound role", { # role_orphan (no partOf) and role_bad (partOf points at non-Framework) # must be excluded. expect_equal(nrow(result), 3) - expect_named(result, c("role", "framework", "role_name", "framework_name")) + expect_named(result, c("role", "framework", "role_name", "framework_name", + "framework_slug")) expect_false(any(grepl("fixture-orphan", result$role))) expect_false(any(grepl("fixture-bad-partof", result$role))) @@ -128,7 +130,8 @@ test_that("element_framework_bindings returns one row per bound element", { # Five parent elements + five Subpoints + one Example all have partOf; # el_orphan must be excluded. expect_equal(nrow(result), 11) - expect_named(result, c("element", "framework", "framework_name")) + expect_named(result, c("element", "framework", "framework_name", + "framework_slug")) expect_false(any(grepl("fixture-el-orphan", result$element))) }) @@ -147,25 +150,25 @@ test_that("element_framework_bindings splits correctly across frameworks", { }) # --------------------------------------------------------------------------- -# Domain helper: role_element_bindings +# Domain helper: unit_element_bindings # --------------------------------------------------------------------------- -test_that("role_element_bindings returns one row per cybed:hasElement triple", { +test_that("unit_element_bindings returns one row per cybed:hasElement triple", { skip_if_no_rdflib() rdf <- make_fixture_graph() - result <- role_element_bindings(rdf) + result <- unit_element_bindings(rdf) # 5 original hasElement triples + 5 sub-points-as-cluster-children = 10 expect_equal(nrow(result), 10) - expect_named(result, c("role", "element")) + expect_named(result, c("role", "element", "framework_slug")) }) -test_that("role_element_bindings counts elements per role correctly", { +test_that("unit_element_bindings counts elements per role correctly", { skip_if_no_rdflib() rdf <- make_fixture_graph() - result <- role_element_bindings(rdf) + result <- unit_element_bindings(rdf) per_role <- as.data.frame(table(result$role), stringsAsFactors = FALSE) names(per_role) <- c("role", "n") @@ -181,11 +184,11 @@ test_that("role_element_bindings counts elements per role correctly", { expect_equal(b1, 5) }) -test_that("role_element_bindings is empty when no hasElement triples exist", { +test_that("unit_element_bindings is empty when no hasElement triples exist", { skip_if_no_rdflib() rdf <- rdflib::rdf() - result <- role_element_bindings(rdf) + result <- unit_element_bindings(rdf) expect_equal(nrow(result), 0) }) @@ -315,7 +318,7 @@ test_that("sub-points are reachable from clusters via cybed:hasElement (cluster skip_if_no_rdflib() rdf <- make_fixture_graph() - bindings <- role_element_bindings(rdf) + bindings <- unit_element_bindings(rdf) # role_a1's children should include both parents AND sub-points a1_children <- bindings$element[grepl("fixture-a1$", bindings$role)] @@ -366,10 +369,11 @@ test_that("organizing_unit_framework_bindings returns the same rows as role_fram # the same number of rows here. On the eight-framework graph, # organizing_unit_framework_bindings returns more rows than # role_framework_bindings because non-workforce frameworks (SFIA, - # Cyber.org K-12, CSTA, CSEC2017, DigComp 2.2) contribute units that + # Cyber.org K-12, CSTA, CSEC2017, DigComp 3.0) contribute units that # are not cybed:Role. expect_equal(nrow(ofb), nrow(rfb)) - expect_named(ofb, c("unit", "framework", "unit_name", "framework_name")) + expect_named(ofb, c("unit", "framework", "unit_name", "framework_name", + "framework_slug")) }) test_that("organizing_unit_framework_bindings excludes orphan and bad-partof units", { @@ -399,7 +403,8 @@ test_that("example_framework_bindings returns the single fixture Example", { efb <- example_framework_bindings(rdf) expect_equal(nrow(efb), 1) - expect_named(efb, c("example", "framework", "framework_name")) + expect_named(efb, c("example", "framework", "framework_name", + "framework_slug")) expect_match(efb$example, "fixture-el-a2\\.example\\.1$") expect_equal(efb$framework_name, "Fixture Framework A") }) @@ -412,7 +417,8 @@ test_that("subpoint_framework_bindings returns all five fixture Subpoints", { # FW A: a1.sub.1, a1.sub.2 (2); FW B: b1.sub.1-3 (3); 5 total. expect_equal(nrow(sfb), 5) - expect_named(sfb, c("subpoint", "framework", "framework_name")) + expect_named(sfb, c("subpoint", "framework", "framework_name", + "framework_slug")) per_fw <- table(sfb$framework_name) expect_equal(unname(per_fw["Fixture Framework A"]), 2) expect_equal(unname(per_fw["Fixture Framework B"]), 3) @@ -435,7 +441,7 @@ test_that("Examples are excluded from default cybed:hasElement traversals", { skip_if_no_rdflib() rdf <- make_fixture_graph() - bindings <- role_element_bindings(rdf) + bindings <- unit_element_bindings(rdf) # The fixture's single Example (fixture-el-a2.example.1) is reachable # only via el_a2's cybed:hasExample. It must NOT appear as an element @@ -524,3 +530,47 @@ test_that("sub-points round-trip through JSON-LD assemble + parse", { expect_equal(nrow(elaborates), 3) expect_true(all(elaborates$o == "https://nice.nist.gov/framework/terms#P1")) }) + +# --------------------------------------------------------------------------- +# role_element_bindings() deprecation (v0.4.0) +# --------------------------------------------------------------------------- + +test_that("role_element_bindings warns and is otherwise identical to unit_element_bindings", { + skip_if_no_rdflib() + rdf <- make_fixture_graph() + + expect_warning( + deprecated <- role_element_bindings(rdf), + class = "warning" + ) + current <- unit_element_bindings(rdf) + + expect_equal(deprecated, current) +}) + +# --------------------------------------------------------------------------- +# Domain helper: unit_relation_bindings (v0.4.0) +# --------------------------------------------------------------------------- + +test_that("unit_relation_bindings returns one row per UnitRelation node", { + skip_if_no_rdflib() + rdf <- make_fixture_graph() + + result <- unit_relation_bindings(rdf) + + expect_named(result, c("from_unit", "relation", "to_unit", "framework_slug")) + expect_equal(nrow(result), 2) + expect_true(all(result$relation == "requires")) + expect_true(all(grepl("fixture-a1$", result$from_unit))) + expect_true(all(grepl("fixture-a2$", result$to_unit))) + expect_true(all(result$framework_slug == "fixture-fw-a")) +}) + +test_that("unit_relation_bindings is empty when no UnitRelation nodes exist", { + skip_if_no_rdflib() + rdf <- rdflib::rdf() + + result <- unit_relation_bindings(rdf) + expect_equal(nrow(result), 0) + expect_named(result, c("from_unit", "relation", "to_unit", "framework_slug")) +}) diff --git a/vignettes/adding-a-framework.Rmd b/vignettes/adding-a-framework.Rmd index 036af0f..efcc607 100644 --- a/vignettes/adding-a-framework.Rmd +++ b/vignettes/adding-a-framework.Rmd @@ -20,9 +20,9 @@ knitr::opts_chunk$set( ## Scope of this vignette -Extending `cybedtools` with a framework beyond the current eleven (NICE, DCWF, SFIA, ECSF, Cyber.org K-12, CSTA, CSEC2017, DigComp 2.2, CyQUAL, CCSSF, OTCCF) is a six-step process. This vignette walks through the steps using a hypothetical "Framework X" to make the pattern concrete. +Extending `cybedtools` with a framework beyond the current corpus (NICE, DCWF, SFIA, ECSF, Cyber.org K-12, CSTA, CSEC2017, DigComp 3.0, CyQUAL, CCSSF, OTCCF) follows a fixed sequence of steps. This vignette walks through the steps using a hypothetical "Framework X" to make the pattern concrete. -The six steps: +The steps: 1. Pick a slug and framework prefix. 2. Stage the source file and write an ingestion script. @@ -168,9 +168,9 @@ frameworkx = list( In `scripts/020-assemble-jsonld.R`, add an assembler function and register it. The assembler chooses one of two parent-unit constructors depending on whether the framework is workforce-shaped: - Use `build_role_node()` (which delegates to `build_organizing_unit_node(is_role = TRUE)`) when the framework's parent units are work roles or work profiles. NICE, DCWF, and ENISA ECSF do this. -- Use `build_organizing_unit_node(is_role = FALSE)` when the parent units are something else (skills, learning standards clusters, knowledge areas, competence areas). SFIA, Cyber.org K-12, CSTA, CSEC2017, and DigComp 2.2 do this. +- Use `build_organizing_unit_node(is_role = FALSE)` when the parent units are something else (skills, learning standards clusters, knowledge areas, competence areas). SFIA, Cyber.org K-12, CSTA, CSEC2017, and DigComp 3.0 do this. -Both paths assert `cybed:OrganizingUnit` so cross-framework queries reach all eleven frameworks via the abstract type. Only `build_role_node()` additionally asserts `cybed:Role`, restricting workforce-only queries appropriately. +Both paths assert `cybed:OrganizingUnit` so cross-framework queries reach every framework in the corpus via the abstract type. Only `build_role_node()` additionally asserts `cybed:Role`, restricting workforce-only queries appropriately. The example below assumes Framework X is workforce-shaped: @@ -354,7 +354,7 @@ build_framework_node( ## What if the steward limits what may be republished? -Permission to build a graph is not always permission to publish the framework's text. Record what the steward actually granted in the framework's block in `docs/framework-invariants.yml`, under `public_redistribution`. Two values are in use. `full_with_attribution` means the statement text may be published as long as the attribution travels with it, which is where CyQUAL sits. `structure_only` means titles, categories, levels, and mappings may be published and the statement text may not, which is where CCSSF and OTCCF sit. Put the reasoning in a comment beside the value, including the date of the written permission, so the next reader does not have to reconstruct it. +Permission to build a graph is not always permission to publish the framework's text. Record what the steward actually granted in the framework's block in `docs/framework-invariants.yml`, under `public_redistribution`. Two values are in use. `full_with_attribution` means the statement text may be published as long as the attribution travels with it, which is where CyQUAL and CCSSF sit. `structure_only` means titles, categories, levels, and mappings may be published and the statement text may not, which is where OTCCF sits. Put the reasoning in a comment beside the value, including the date of the written permission, so the next reader does not have to reconstruct it. Treat the value as binding on anything the package makes public, which includes the Concordance site and any data deposit. Analysis on a local graph is unaffected. If a steward's terms do not fit either value, add a new one and say so in the comment rather than rounding down to the nearest existing label. diff --git a/vignettes/articles/ai-assistants.Rmd b/vignettes/articles/ai-assistants.Rmd index e05bda0..b38f932 100644 --- a/vignettes/articles/ai-assistants.Rmd +++ b/vignettes/articles/ai-assistants.Rmd @@ -8,6 +8,14 @@ description: > ```{r} #| include: false knitr::opts_chunk$set(collapse = TRUE, comment = "#>") + +# framework_summary ships as lazy-loaded package data, so this always +# evaluates without a staged graph. Keeps the role-framework list below +# from drifting out of sync with a re-ingested framework. +fs_ai <- cybedtools::framework_summary +role_fw_names_ai <- fs_ai$framework_name[!is.na(fs_ai$role_count)] +role_fw_list_ai <- paste(role_fw_names_ai, collapse = ", ") +nonrole_fw_count_ai <- sum(is.na(fs_ai$role_count)) ``` This article has two readers: a person who wants an assistant to write cybedtools code, and the assistant itself, reading its Markdown twin. An assistant working from general R and SPARQL knowledge will get several things about this package wrong. @@ -95,7 +103,7 @@ organizing_unit_framework_bindings(rdf_full) role_framework_bindings(rdf_full) ``` -`cybed:OrganizingUnit` reaches all eleven frameworks. `cybed:Role` reaches the six that declare roles: NICE, DCWF, ECSF, CyQUAL, CCSSF, and OTCCF. A query written against `cybed:Role` when the question is corpus-wide drops five frameworks without saying so. +`cybed:OrganizingUnit` reaches every framework in the corpus. `cybed:Role` reaches only the frameworks that declare roles: `r role_fw_list_ai`. A query written against `cybed:Role` when the question is corpus-wide silently drops the other `r nonrole_fw_count_ai` frameworks. ### Respect the upstream licenses @@ -106,7 +114,7 @@ framework_metadata(rdf_full) |> select(name, jurisdiction, sector) ``` -Package code is MIT. Framework content keeps its upstream terms. `docs/framework-invariants.yml` records a `public_redistribution` policy per framework: SFIA is `local_only`, CSEC2017, CCSSF and OTCCF are `structure_only`, CyQUAL is `full_with_attribution`. OTCCF and CyQUAL are in the package by the written permission of their stewards. CCSSF is not: its attribution is "Copyright Government of Canada. Referenced as the Canadian Centre for Cyber Security asked." Do not reproduce statement text from a framework marked `local_only` or `structure_only`. Titles, counts, categories and mappings are fine. See `LICENSE.md` and `LICENSING.md`. +Package code is MIT. Framework content keeps its upstream terms. `docs/framework-invariants.yml` records a `public_redistribution` policy per framework: SFIA is `local_only`, CSEC2017 and OTCCF are `structure_only`, CyQUAL and CCSSF are `full_with_attribution`. OTCCF, CyQUAL and CCSSF are in the package by the written permission of their stewards. CCSSF's attribution is "Copyright Government of Canada. Used with permission of the Canadian Centre for Cyber Security." Do not reproduce statement text from a framework marked `local_only` or `structure_only`. Titles, counts, categories and mappings are fine. See `LICENSE.md` and `LICENSING.md`. ### Cite the package and the stewards diff --git a/vignettes/articles/data-integrity.Rmd b/vignettes/articles/data-integrity.Rmd index 1518f67..cb40aab 100644 --- a/vignettes/articles/data-integrity.Rmd +++ b/vignettes/articles/data-integrity.Rmd @@ -1,7 +1,7 @@ --- title: "Data integrity protocol" description: > - Six invariants every framework ingestion must satisfy before downstream + The invariants every framework ingestion must satisfy before downstream analysis runs, plus the audit-trail mechanics that keep the pipeline honest. --- diff --git a/vignettes/articles/namespace-architecture.Rmd b/vignettes/articles/namespace-architecture.Rmd index cfa6cf3..bce62aa 100644 --- a/vignettes/articles/namespace-architecture.Rmd +++ b/vignettes/articles/namespace-architecture.Rmd @@ -9,6 +9,14 @@ description: > ```{r} #| include: false knitr::opts_chunk$set(collapse = TRUE, comment = "#>") + +# framework_summary ships as lazy-loaded package data, so this always +# evaluates without a staged graph. Keeps the total-statement figure +# below from drifting out of sync with a re-ingested framework. +statement_total_fmt <- format( + sum(cybedtools::framework_summary$element_count_strict), + big.mark = "," +) ``` The core tension is that each framework uses its own vocabulary (work roles, tasks, competences, skills, levels, learning standards) while comparative analysis needs a framework-agnostic layer that lets SPARQL queries operate uniformly across them. @@ -17,7 +25,7 @@ The core tension is that each framework uses its own vocabulary (work roles, tas The architecture uses a two-tier namespace structure: a base vocabulary that is cross-framework, and framework-specific vocabularies that subclass the base. Each tier has a distinct prefix. -The base vocabulary subsumes the conceptual common ground of all in-scope frameworks: framework, organizing unit (the framework's top-level enumerated unit, whether a work role, skill, learning standard cluster, knowledge area, or competence area), role element (task, knowledge statement, skill statement, competence, learning standard), sub-point, example, element text, source reference, and structural metadata (jurisdiction, sector, specificity). Per-framework vocabularies specialize these via subclassing, so a single SPARQL query targeting `cybed:OrganizingUnit` returns parent units across all eleven frameworks in one pass. +The base vocabulary subsumes the conceptual common ground of all in-scope frameworks: framework, organizing unit (the framework's top-level enumerated unit, whether a work role, skill, learning standard cluster, knowledge area, or competence area), role element (task, knowledge statement, skill statement, competence, learning standard), sub-point, example, element text, source reference, and structural metadata (jurisdiction, sector, specificity). Per-framework vocabularies specialize these via subclassing, so a single SPARQL query targeting `cybed:OrganizingUnit` returns parent units across every framework in the corpus in one pass. Schema.org (`schema:`) and SKOS (`skos:`) provide the outermost vocabulary layer for generic properties such as name, description, prefLabel, and altLabel. `cybed:OrganizingUnit` itself is `subClassOf skos:Concept` for downstream interoperability with the wider linked-data ecosystem. @@ -93,11 +101,11 @@ flowchart TD class SP,EX parsed ``` -A single SPARQL query against `cybed:OrganizingUnit` returns comparable parent-level bindings across all eleven frameworks at once. A query against `cybed:RoleElement` returns every atomic content node (parents, Subpoints, and Examples). A query against `cybed:Role` is workforce-restricted to NICE / DCWF / ENISA ECSF / CyQUAL / CCSSF / OTCCF. +A single SPARQL query against `cybed:OrganizingUnit` returns comparable parent-level bindings across every framework in the corpus at once. A query against `cybed:RoleElement` returns every atomic content node (parents, Subpoints, and Examples). A query against `cybed:Role` is workforce-restricted to NICE / DCWF / ENISA ECSF / CyQUAL / CCSSF / OTCCF. ### Tier 1: Base vocabulary (`cybed:`) -Framework-agnostic terms. `cybed:OrganizingUnit` is the cross-framework abstract; every framework's parent type subclasses it, and queries against it reach all eleven frameworks. `cybed:Role` is reserved for workforce frameworks where the unit is genuinely a work role or work profile. +Framework-agnostic terms. `cybed:OrganizingUnit` is the cross-framework abstract; every framework's parent type subclasses it, and queries against it reach every framework in the corpus. `cybed:Role` is reserved for workforce frameworks where the unit is genuinely a work role or work profile. - `cybed:Framework`, top-level container for a specific framework. - `cybed:OrganizingUnit`, `subClassOf skos:Concept`. The cross-framework abstract for any framework's top-level enumerated unit (work role, role profile, skill, grade-band x sub-concept cell, level x concept cell, Knowledge Area, competence area). @@ -134,7 +142,7 @@ Each framework has its own prefix. The per-framework parent type subclasses `cyb | Cyber.org K-12 | `cyberorg:StandardGroup` | `cybed:OrganizingUnit` | `cyberorg:Standard` | | CSTA K-12 CS | `csta:StandardGroup` | `cybed:OrganizingUnit` | `csta:Standard` | | ACM/IEEE CSEC2017 | `csec:KnowledgeArea` | `cybed:OrganizingUnit` | `csec:Essential` | -| DigComp 2.2 | `digcomp:CompetenceArea` | `cybed:OrganizingUnit` | `digcomp:Competence` | +| DigComp 3.0 | `digcomp:CompetenceArea` | `cybed:OrganizingUnit` | `digcomp:Competence` | | CyQUAL 1.2.0 | `cyqual:WorkRole` | `cybed:Role` | `cyqual:Task`, `cyqual:Requirement` | | CyQUAL 1.2.0 | `cyqual:Competency` | `cybed:OrganizingUnit` | `cyqual:Requirement` | | CCSSF 2022 | `ccssf:WorkRole`, `ccssf:AdjacentRole` | `cybed:Role` | `ccssf:Task`, `ccssf:Competency`, `ccssf:ToolOrTechnology` | @@ -143,9 +151,9 @@ Each framework has its own prefix. The per-framework parent type subclasses `cyb ### Unit IRIs: when a local id is not enough -A node's IRI is its prefix plus its framework-local id. That works as long as a framework numbers its organizing units and its statements out of separate id spaces, because then no unit id can equal a statement id and no two nodes can land on the same IRI. Nine of the eleven frameworks are like that. +A node's IRI is its prefix plus its framework-local id. That works as long as a framework numbers its organizing units and its statements out of separate id spaces, because then no unit id can equal a statement id and no two nodes can land on the same IRI. Most frameworks in the corpus are like that. -Two are not. DCWF draws work-role codes and task/KSA numbers from one numeric range, so work role 462 and statement 462 both wanted `dcwf:462`. Cyber.org K-12 names a grade-band x sub-concept cell after its axes and names the standards inside it the same way, so a cell holding exactly one standard wanted that standard's IRI. Where that happened the two nodes did not conflict, they merged: one IRI carrying a unit's types and a statement's types at once, and a `cybed:hasElement` edge running from the node to itself. +A few are not. DCWF draws work-role codes and task/KSA numbers from one numeric range, so work role 462 and statement 462 both wanted `dcwf:462`. Cyber.org K-12 names a grade-band x sub-concept cell after its axes and names the standards inside it the same way, so a cell holding exactly one standard wanted that standard's IRI. Where that happened the two nodes did not conflict, they merged: one IRI carrying a unit's types and a statement's types at once, and a `cybed:hasElement` edge running from the node to itself. The fix is a discriminator on the unit side only. A framework may declare a `unit_iri_prefix` in `docs/framework-invariants.yml`; the assembler prepends it to the unit's local id when minting the IRI, and the unit's printed code is kept as a `schema:identifier` literal so nothing the IRI used to carry is lost. DCWF declares `role-`, Cyber.org K-12 declares `cell-`. @@ -156,13 +164,13 @@ The fix is a discriminator on the unit side only. A framework may declare a `uni Three properties of the rule matter more than the rule itself. -It is one-sided. Statement IRIs do not move. They are the codes people cite, there are about 4,500 of them, and a framework's own numbering is what a reader looks up. +It is one-sided. Statement IRIs do not move. They are the codes people cite, there are `r statement_total_fmt` of them (parent statements, strict count), and a framework's own numbering is what a reader looks up. It is declared, not inferred. A framework with no `unit_iri_prefix` mints the IRIs it has always minted, so the other nine frameworks' graphs are byte-identical to what they were before the rule existed. `build_organizing_unit_node()` and `build_role_node()` take `unit_iri_prefix` as an optional argument that defaults to no discriminator, so a framework of your own is unaffected unless you ask for it. It is enforced. `assert_graph_identity()` fails the build if any IRI is typed both `cybed:OrganizingUnit` and `cybed:RoleElement`, or if any `cybed:hasElement` triple points at its own subject. There is no exemption list. A framework that would need one needs a `unit_iri_prefix` instead. -The three frameworks added in v0.3.0 mint their Tier 2 terms under `https://w3id.org/cybed/framework/#` rather than under the steward's own domain. The other eight sit under a namespace derived from the publisher's site. The difference is deliberate. A term like `otccf:JobRole` is a type cybedtools coined to describe what CSA published, and it is not an identifier CSA issued. Putting it under the package's own namespace keeps that distinction visible in the URI itself, so nobody reading the graph mistakes a package-coined subtype for the steward's vocabulary. +The national frameworks added after the package's original release mint their Tier 2 terms under `https://w3id.org/cybed/framework/#` rather than under the steward's own domain. The rest sit under a namespace derived from the publisher's site. The difference is deliberate. A term like `otccf:JobRole` is a type cybedtools coined to describe what CSA published, and it is not an identifier CSA issued. Putting it under the package's own namespace keeps that distinction visible in the URI itself, so nobody reading the graph mistakes a package-coined subtype for the steward's vocabulary. ## Example JSON-LD documents diff --git a/vignettes/articles/sparql-strategy.Rmd b/vignettes/articles/sparql-strategy.Rmd index f2159e0..4217a01 100644 --- a/vignettes/articles/sparql-strategy.Rmd +++ b/vignettes/articles/sparql-strategy.Rmd @@ -8,6 +8,35 @@ description: > ```{r} #| include: false knitr::opts_chunk$set(collapse = TRUE, comment = "#>") + +# Every figure in "What these queries surface" comes from framework_summary, +# lazy-loaded package data, so this chunk always evaluates without a staged +# graph. Nothing below is hand-typed. +fs_sq <- cybedtools::framework_summary + +density_max_row_sq <- fs_sq[which.max(fs_sq$elements_per_organizing_unit_with_examples), ] +density_min_row_sq <- fs_sq[which.min(fs_sq$elements_per_organizing_unit_with_examples), ] +density_ratio_sq <- round( + density_max_row_sq$elements_per_organizing_unit_with_examples / + density_min_row_sq$elements_per_organizing_unit_with_examples +) + +strict_max_row_sq <- fs_sq[which.max(fs_sq$elements_per_organizing_unit_strict), ] +strict_min_row_sq <- fs_sq[which.min(fs_sq$elements_per_organizing_unit_strict), ] +strict_ratio_sq <- round( + strict_max_row_sq$elements_per_organizing_unit_strict / + strict_min_row_sq$elements_per_organizing_unit_strict +) + +juris_totals_sq <- stats::aggregate( + element_count_with_examples ~ jurisdiction, data = fs_sq, FUN = sum +) +us_total_sq <- juris_totals_sq$element_count_with_examples[juris_totals_sq$jurisdiction == "US"] +all_total_sq <- sum(juris_totals_sq$element_count_with_examples) +us_share_pct_sq <- round(100 * us_total_sq / all_total_sq) + +role_frameworks_sq <- fs_sq$framework_name[!is.na(fs_sq$role_count)] +role_frameworks_list_sq <- paste(role_frameworks_sq, collapse = ", ") ``` The queries operate against the unified RDF graph produced by the loaders in `R/rdf-graph.R` and, for query execution, the combined N-Triples file produced by `scripts/025-export-ntriples.R`. @@ -19,15 +48,15 @@ The package supports two kinds of question: - **Within-framework questions** (such as "which task statements in NICE involve cloud or container technologies?"). - **Across-framework questions** (such as "how do role and element counts vary by jurisdiction or sector?"). -The cybed: base vocabulary is the selection path for both. A single helper call targeting `cybed:OrganizingUnit` returns comparable parent-level bindings across all eleven frameworks; targeting `cybed:Role` restricts to the six that declare roles (NICE, DCWF, ECSF, CyQUAL, CCSSF, OTCCF); targeting `cybed:RoleElement` returns atomic content nodes (parents, Subpoints, and Examples). +The cybed: base vocabulary is the selection path for both. A single helper call targeting `cybed:OrganizingUnit` returns comparable parent-level bindings across every framework in the corpus; targeting `cybed:Role` restricts to the frameworks that declare roles (`r role_frameworks_list_sq`); targeting `cybed:RoleElement` returns atomic content nodes (parents, Subpoints, and Examples). ### What these queries surface -Three findings the package's analytical layer produces directly from the eleven-framework graph: +Findings the package's analytical layer produces directly from the corpus graph: -- Element density per framework varies by ~13x with Cyber.org K-12 / CSTA pedagogical Examples included (DCWF 54.8 elements per work role, DigComp 4.2 per competence area). Without Examples the spread widens to ~38x because Cyber.org K-12's 116 cells contain only 123 numbered standards. Per-unit density is a comparison aid across heterogeneous denominators, not a quality claim. -- US frameworks (NICE, DCWF, Cyber.org K-12, CSTA) hold about half the corpus by element count, and an order of magnitude more than the two EU-level frameworks (ECSF, DigComp). That gap reflects design philosophy (ECSF is profile-level by intent, DigComp is a citizen self-assessment instrument), and two of the three national frameworks added in 0.3.0 are built on NICE by their own account, so jurisdiction alone understates US-authored content. -- The five highest-element-load NICE work roles concentrate disproportionate competency specification (Security Control Assessment 304, Secure Systems Development 237, Cybersecurity Architecture 218, Defensive Cybersecurity 205, Systems Security Management 201). +- Element density per framework varies by roughly `r density_ratio_sq`x with Cyber.org K-12 / CSTA pedagogical Examples included (`r density_max_row_sq$framework_name` at `r density_max_row_sq$elements_per_organizing_unit_with_examples` elements per top-level unit, `r density_min_row_sq$framework_name` at `r density_min_row_sq$elements_per_organizing_unit_with_examples`). Without Examples the spread is roughly `r strict_ratio_sq`x (`r strict_max_row_sq$framework_name` at `r strict_max_row_sq$elements_per_organizing_unit_strict`, `r strict_min_row_sq$framework_name` at `r strict_min_row_sq$elements_per_organizing_unit_strict`). Per-unit density is a comparison aid across heterogeneous denominators, not a quality claim. +- US frameworks (NICE, DCWF, Cyber.org K-12, CSTA) hold about `r us_share_pct_sq` percent of the corpus by element count, well above the EU-level frameworks (ECSF, DigComp). That gap reflects design philosophy (ECSF is profile-level by intent, DigComp is a citizen self-assessment instrument), and some of the national frameworks added after the original release are built on NICE by their own account, so jurisdiction alone understates US-authored content. +- A small number of NICE work roles concentrate disproportionate competency specification. See the [`cross-framework-analysis`](cross-framework-analysis.html) vignette's "Largest organizing units" recipe and the README's live figures for the current ranking. See the [`cross-framework-analysis`](cross-framework-analysis.html) vignette for the worked R that produces them. @@ -62,7 +91,7 @@ Each domain helper makes one to four single-BGP queries and joins the results vi - **A1. Framework metadata inventory.** One row per framework with jurisdiction, sector, specificity. Uses `framework_metadata()`. - **A2. Organizing units per framework and elements per framework.** Aggregated in R from `organizing_unit_framework_bindings()` and `element_framework_bindings()`. For workforce-restricted aggregations, swap in `role_framework_bindings()`. -- **A3. Element density.** Elements per top-level unit per framework, joining `role_element_bindings()` with `organizing_unit_framework_bindings()`. Surfaces the cross-framework structural-density spread (with-examples count): DCWF around 54.8 per work role, NICE around 42.0 per organizing unit (its work roles and competency areas together), ECSF around 32.5 per role profile, OTCCF around 26.4, CCSSF around 22.8, CyQUAL around 20.7, CSTA around 10.3 per level-x-concept cell, SFIA around 5.6 per skill, CSEC2017 around 5.0 per Knowledge Area, Cyber.org K-12 around 4.2 per grade-band-x-sub-concept cell, DigComp around 4.2 per competence area. +- **A3. Element density.** Elements per top-level unit per framework, joining `role_element_bindings()` with `organizing_unit_framework_bindings()`. Surfaces the cross-framework structural-density spread (with-examples count), from the granular workforce frameworks at the high end to the high-level interoperability and self-assessment frameworks at the low end. Current per-framework figures are in `cybedtools::framework_summary` and the density chart in the [`cross-framework-analysis`](cross-framework-analysis.html) vignette. - **A4. Missing required properties.** Quality control. Surface RoleElement subjects without `cybed:elementText` (use `sparql_subjects()` and dplyr `anti_join`). ### Family B: Cross-framework pivots @@ -72,7 +101,7 @@ Each domain helper makes one to four single-BGP queries and joins the results vi - **B3. Element volume by specificity.** Same shape, on `framework_metadata()$specificity`. - **B4. Framework-vs-framework structural comparison.** Filter `role_framework_bindings()` to two frameworks and compare role counts, element-per-role distributions. -The runner (`scripts/040-run-sparql.R`) implements six named analyses (q10 through q15) that map onto these families and write one CSV per analysis to `data/processed/query-results/`. +The runner (`scripts/040-run-sparql.R`) implements the named analyses (q10 through q15) that map onto these families and write one CSV per analysis to `data/processed/query-results/`. ## Implementation notes diff --git a/vignettes/cross-framework-analysis.Rmd b/vignettes/cross-framework-analysis.Rmd index afe95aa..b5722d9 100644 --- a/vignettes/cross-framework-analysis.Rmd +++ b/vignettes/cross-framework-analysis.Rmd @@ -20,21 +20,71 @@ knitr::opts_chunk$set( ## What you'll find here -The eleven frameworks in cybedtools were authored by different bodies for different audiences (NIST and DoD writing for the US workforce, ENISA and JRC writing for the EU citizen and policy audiences, Cyber.org and CSTA writing for K-12 educators, ACM/IEEE for higher-ed curricula, SFIA Foundation for the global IT-skills market, and national bodies in the Czech Republic, Canada, and Singapore writing for their own labour markets). They were not all authored independently. Canada's framework presents itself as an adaptation of NICE, and CyQUAL says its structure and elements were adopted from NICE. Lineage like that is one of the things a shared graph makes visible. They specify at incommensurable units of analysis: work role, skill level, competence, learning standard, Knowledge Area, competence area. They reflect different design philosophies: granular specification versus profile-level interoperability frames versus citizen self-assessment instruments. They do not agree on what to count, how to count it, or how to organize what they count. +The frameworks in cybedtools were authored by different bodies for different audiences (NIST and DoD writing for the US workforce, ENISA and JRC writing for the EU citizen and policy audiences, Cyber.org and CSTA writing for K-12 educators, ACM/IEEE for higher-ed curricula, SFIA Foundation for the global IT-skills market, and national bodies in the Czech Republic, Canada, and Singapore writing for their own labour markets). They were not all authored independently. Canada's framework presents itself as an adaptation of NICE, and CyQUAL says its structure and elements were adopted from NICE. Lineage like that is one of the things a shared graph makes visible. They specify at incommensurable units of analysis: work role, skill level, competence, learning standard, Knowledge Area, competence area. They reflect different design philosophies: granular specification versus profile-level interoperability frames versus citizen self-assessment instruments. They do not agree on what to count, how to count it, or how to organize what they count. That heterogeneity is the reason a cross-framework comparison layer needs to exist. cybedtools does not erase the differences. It makes them queryable. -The findings below are what shows up when you put eleven differently-organized frameworks in the same graph and run the same queries against each. Read them as demonstrations of what the comparison layer surfaces, not as claims about which framework is "more thorough" or "more granular" in absolute terms. +The findings below are what shows up when you put the corpus's differently-organized frameworks in the same graph and run the same queries against each. Read them as demonstrations of what the comparison layer surfaces, not as claims about which framework is "more thorough" or "more granular" in absolute terms. -### Element volume varies by ~140x across the corpus +```{r} +#| label: vignette-summary-setup +#| eval: true +#| include: false +# Every figure cited in this section comes from framework_summary, which +# ships with the package as lazy-loaded data -- no staged graph required, +# so this chunk always evaluates. Nothing below is hand-typed. +fs_v <- cybedtools::framework_summary + +pick_v <- function(pattern, col) { + hit <- grep(pattern, fs_v$framework_name) + stopifnot(length(hit) == 1) + fs_v[[col]][hit] +} + +# Broad (with-examples) density extremes, picked by value rather than by +# framework name, so a re-ingest that changes which framework sits at +# either end updates the prose instead of silently mismatching it. +density_max_row_v <- fs_v[which.max(fs_v$elements_per_organizing_unit_with_examples), ] +density_min_row_v <- fs_v[which.min(fs_v$elements_per_organizing_unit_with_examples), ] +density_max_name_v <- density_max_row_v$framework_name +density_min_name_v <- density_min_row_v$framework_name +density_max_v <- density_max_row_v$elements_per_organizing_unit_with_examples +density_min_v <- density_min_row_v$elements_per_organizing_unit_with_examples +broad_ratio_v <- round(density_max_v / density_min_v) + +elements_max_row_v <- fs_v[which.max(fs_v$element_count_with_examples), ] +elements_min_row_v <- fs_v[which.min(fs_v$element_count_with_examples), ] +elements_max_name_v <- elements_max_row_v$framework_name +elements_min_name_v <- elements_min_row_v$framework_name +elements_max_v <- format(elements_max_row_v$element_count_with_examples, big.mark = ",") +elements_min_v <- format(elements_min_row_v$element_count_with_examples, big.mark = ",") + +nice_strict_v <- pick_v("^NICE", "elements_per_organizing_unit_strict") +cyberorg_strict_v <- pick_v("^Cyber\\.org", "elements_per_organizing_unit_strict") +strict_ratio_v <- round(nice_strict_v / cyberorg_strict_v) +nice_units_v <- pick_v("^NICE", "organizing_unit_count") +nice_roles_v <- pick_v("^NICE", "role_count") +nice_nonrole_units_v <- nice_units_v - nice_roles_v + +juris_totals_v <- stats::aggregate( + element_count_with_examples ~ jurisdiction, data = fs_v, FUN = sum +) +us_total_v <- juris_totals_v$element_count_with_examples[juris_totals_v$jurisdiction == "US"] +eu_total_v <- juris_totals_v$element_count_with_examples[juris_totals_v$jurisdiction == "EU"] +all_total_v <- sum(juris_totals_v$element_count_with_examples) +us_share_pct_v <- round(100 * us_total_v / all_total_v) +eu_of_us_pct_v <- round(100 * eu_total_v / us_total_v) +``` + +### Element volume varies widely across the corpus -DCWF declares 4,052 elements; DigComp 2.2 declares 21. Per-unit density correspondingly spreads from DCWF's 54.8 elements per work role to DigComp's 4.2 elements per competence area, a roughly 13x ratio when Cyber.org K-12 and CSTA's Clarification-statement Examples are included. The spread reflects each framework's design philosophy more than care or completeness: NICE/DCWF are granular by design as the basis for hiring and training pipelines; ECSF and DigComp are intentionally high-level as interoperability frames and citizen self-assessment instruments. cybedtools does not normalize across this asymmetry; downstream analyses that aggregate "framework coverage" should account for it explicitly. +`r elements_max_name_v` declares `r elements_max_v` elements; `r elements_min_name_v` declares `r elements_min_v`. Per-unit density spreads correspondingly: `r density_max_name_v` sits at `r density_max_v` elements per top-level unit against `r density_min_name_v` at `r density_min_v`, roughly a `r broad_ratio_v`x ratio, counted with Cyber.org K-12 and CSTA's Clarification-statement Examples included. The spread reflects each framework's design philosophy more than care or completeness: granular frameworks built as the basis for hiring and training pipelines sit at one end; high-level interoperability frames and citizen self-assessment instruments sit at the other. cybedtools does not normalize across this asymmetry; downstream analyses that aggregate "framework coverage" should account for it explicitly. -For analyses that need to compare frameworks at their own native granularity, `framework_summary` carries `elements_per_organizing_unit_strict`, which excludes both `cybed:Subpoint` and `cybed:Example` instances -- every layer of tool-parsed augmentation, not just pedagogical scaffolding. Under the strict count Cyber.org K-12 drops to ~1.1 elements per cell (numbered standards only) and the cross-framework spread widens to ~38x (NICE at 41.7 against Cyber.org K-12 at 1.1). Earlier releases reported a wider figure. NICE's strict per-unit density fell when its 11 competency areas joined its 42 work roles in the denominator, which is a change in what is being counted and not in NICE. `framework_summary` now also carries `elements_per_role_strict` for the frameworks that declare roles, which keeps the denominator to roles alone. The strict view is structurally honest about each framework's normative content but invites the misreading "Cyber.org K-12 specifies less content than NICE" when the more accurate framing is that the two frameworks organize at different denominator granularities (work role vs grade-band x sub-concept cell). +For analyses that need to compare frameworks at their own native granularity, `framework_summary` carries `elements_per_organizing_unit_strict`, which excludes both `cybed:Subpoint` and `cybed:Example` instances -- every layer of tool-parsed augmentation, not just pedagogical scaffolding. Under the strict count the cross-framework spread between NICE (`r nice_strict_v` elements per unit) and Cyber.org K-12 (`r cyberorg_strict_v`) is roughly `r strict_ratio_v`x. NICE's strict per-unit density moves when its competency areas join its work roles in the denominator (`r nice_nonrole_units_v` non-role organizing units alongside `r nice_roles_v` work roles), which is a change in what is being counted and not in NICE. `framework_summary` also carries `elements_per_role_strict` for the frameworks that declare roles, which keeps the denominator to roles alone. The strict view is structurally honest about each framework's normative content but invites the misreading "Cyber.org K-12 specifies less content than NICE" when the more accurate framing is that the two frameworks organize at different denominator granularities (work role vs grade-band x sub-concept cell). ### The corpus skews US-heavy by element volume -US frameworks (NICE, DCWF, Cyber.org K-12, CSTA) contribute about half of the corpus by element count. The two EU-level frameworks (ECSF, DigComp) contribute under one-fifteenth of the US volume, and the rest comes from SFIA, CSEC2017, and the three national frameworks added in 0.3.0 (CyQUAL, Canada's CCSSF, Singapore's OTCCF). Two of those three are built on NICE by their own account, so counting by jurisdiction understates how far US-authored content travels. The US and EU asymmetry itself reflects design philosophy more than relative investment: ENISA designed ECSF as profile-level for national elaboration, and JRC designed DigComp 2.2 as a citizen self-assessment instrument. ECSF profiles also embed e-CF 4.0 cross-references that cybedtools does not currently materialize as triples; full ECSF coverage requires consulting those pointers separately. Researchers using element counts as a coverage metric should attribute the asymmetry to design intent, not corpus completeness. +US frameworks (NICE, DCWF, Cyber.org K-12, CSTA) contribute about `r us_share_pct_v` percent of the corpus by element count. The EU-level frameworks (ECSF, DigComp) together contribute about `r eu_of_us_pct_v` percent of the US volume, and the rest comes from SFIA, CSEC2017, and the additional national frameworks contributed since the package's initial release (CyQUAL, Canada's CCSSF, Singapore's OTCCF). Most of those are built on NICE by their own account, so counting by jurisdiction understates how far US-authored content travels. The US and EU asymmetry itself reflects design philosophy more than relative investment: ENISA designed ECSF as profile-level for national elaboration, and JRC designed DigComp 3.0 as a citizen self-assessment instrument. ECSF profiles also embed e-CF 4.0 cross-references that cybedtools does not currently materialize as triples; full ECSF coverage requires consulting those pointers separately. Researchers using element counts as a coverage metric should attribute the asymmetry to design intent, not corpus completeness. ### Encoding strategy varies across the corpus @@ -44,9 +94,9 @@ cybedtools' sub-point parser lifts both pattern families to first-class graph el `framework_summary` surfaces both views: `element_count_strict` counts native parents only, with `subpoint_count` and `example_count` -- the two tool-parsed augmentation layers -- both excluded; `element_count_with_examples` adds both layers back. The headline density figures used in the README use the with-examples count, which counts what each framework puts in front of a teacher, trainee, or curriculum designer; the strict count is the supplementary figure. The vignette below shows both columns side by side. -### The five highest-element-load NICE work roles concentrate disproportionate specification +### A small number of NICE work roles concentrate disproportionate specification -Security Control Assessment (304 elements), Secure Systems Development (237), Cybersecurity Architecture (218), Defensive Cybersecurity (205), and Systems Security Management (201). Curricula that "cover NICE" by surveying these five look thorough; curricula that cover the long tail of 42 roles look thin by element count alone. This is a property of NICE's internal weighting, not a finding about the corpus. +A handful of NICE work roles (Security Control Assessment, Secure Systems Development, Cybersecurity Architecture, Defensive Cybersecurity, and Systems Security Management among them) carry far more elements than the typical role. Curricula that "cover NICE" by surveying only the heaviest roles look thorough; curricula that cover the long tail of the remaining roles look thin by element count alone. This is a property of NICE's internal weighting, not a finding about the corpus. The "Largest organizing units by element count" recipe below reproduces the current ranking against a staged graph; the README's live figures reflect it against the package's current release. The rest of this vignette is the technical apparatus that produces these and other comparative findings, organized by query family: @@ -93,7 +143,7 @@ Expected output (8 frameworks): # A tibble: 8 × 5 framework name jurisdiction sector specificity -1 https://w3id.org/cybed/.../digcomp-2.2 DigComp 2.2 EU citizen-education general-digital-competence +1 https://w3id.org/cybed/.../digcomp-3.0 DigComp 3.0 EU citizen-education general-digital-competence 2 https://w3id.org/cybed/.../ecsf-v1 ECSF v1 EU civilian cybersecurity-specific 3 https://w3id.org/cybed/.../csec2017-v1 CSEC2017 Curricular Guidelines... global higher-education cybersecurity-specific 4 https://w3id.org/cybed/.../sfia-9 SFIA 9 global general general-IT @@ -149,16 +199,41 @@ element_framework_bindings(rdf) |> count(framework_name, sort = TRUE, name = "element_count_with_examples") ``` -Density varies dramatically across heterogeneous denominators. DCWF has 4,052 elements across 74 work roles (about 55 per work role) -- the corpus's highest density, ahead of NICE. NICE has 2,225 elements, 1,877 of which are attached to at least one of its 42 work roles (about 45 per work role). The rest belong only to its 11 competency areas, a second grouping axis, or to no role at all. CyQUAL and OTCCF are built the same way, with competencies and technical skills beside their roles, so `framework_summary` reports `role_count` and per-role density separately from the per-unit figures. The three national frameworks sit in the middle of the range: CyQUAL about 31 elements per work role, Canada's CCSSF about 23, and OTCCF 18 key tasks per job role, with OTCCF's level statements attached to its skills and not its roles. SFIA 9 has 821 leaf elements (parent skills + parsed Subpoints) across 147 skills (~5.6 per skill). At the other end, Cyber.org K-12 has 123 strict elements across 116 grade-band x sub-concept cells (~1.1 strict per cell), or 492 with-Examples elements (~4.2 with-Examples per cell). DigComp 2.2 has 21 elements across 5 competence areas (~4.2 per area). A schema that generalizes across both extremes is doing real work. +Density varies dramatically across heterogeneous denominators. DCWF and NICE sit at the high end, with tens of elements per work role. NICE's elements are not all role-bound: some attach only to its competency areas, a second grouping axis, or to no role at all. CyQUAL and OTCCF are built the same way, with competencies and technical skills beside their roles, so `framework_summary` reports `role_count` and per-role density separately from the per-unit figures. The three national frameworks (CyQUAL, Canada's CCSSF, and OTCCF) sit in the middle of the range on a per-role or per-job-role basis, with OTCCF's level statements attached to its skills rather than its roles. SFIA sits further down, with a modest multiple of leaf elements per skill. At the other end, Cyber.org K-12 and DigComp are the sparsest: Cyber.org K-12's strict count is close to one element per grade-band x sub-concept cell, rising once pedagogical Examples are included, and DigComp's strict elements sit close to its Competence Statement count per competence area, rising once its Learning Outcome Examples are included. A schema that generalizes across both extremes is doing real work. Exact current counts for every framework, strict and with-examples, are in the table and plot above -- generated from `cybedtools::framework_summary`, never hand-typed here, so this prose never drifts out of sync with a re-ingested framework. The same numbers, plotted from `cybedtools::framework_summary` (no staged graph required, since the data object ships with the package). Hover for organizing-unit and element counts: +```{r} +#| label: density-alt-setup +#| eval: !expr requireNamespace("ggplot2", quietly = TRUE) && requireNamespace("plotly", quietly = TRUE) +#| include: false +# Build the plot's accessibility alt text from framework_summary itself, +# so it can never drift out of sync with a re-ingested framework the way +# a hand-typed alt string would. +fs_alt <- cybedtools::framework_summary +fs_alt <- fs_alt[order(-fs_alt$elements_per_organizing_unit_with_examples), ] +density_alt_text <- paste0( + "Horizontal bar chart of elements per top-level unit across the frameworks ", + "in the corpus (with-examples count), highest to lowest: ", + paste( + sprintf( + "%s at %.1f", + fs_alt$framework_name, + fs_alt$elements_per_organizing_unit_with_examples + ), + collapse = "; " + ), + ". Workforce-content frameworks are colored slate; pedagogical-content ", + "frameworks are colored sky blue." +) +``` + ```{r} #| label: density-plot #| eval: !expr requireNamespace("ggplot2", quietly = TRUE) && requireNamespace("plotly", quietly = TRUE) #| message: false #| warning: false -#| fig.alt: "Horizontal bar chart of elements per top-level unit across the eleven frameworks (with-examples count). DCWF v5.1 leads at 54.8; NICE v2.2.0 follows at 42.0; ECSF v1 at 32.5; OTCCF at 26.4; CCSSF at 22.8; CyQUAL at 20.7. CSTA K-12 CS at 10.3 (lifted by the per-standard clarification examples). SFIA 9 at 5.6. CSEC2017 at 5.0. Cyber.org K-12 at 4.2. DigComp 2.2 at 4.2. Workforce-content frameworks (NICE, DCWF, ECSF, SFIA, CyQUAL, CCSSF, OTCCF) are colored slate; pedagogical-content frameworks (Cyber.org K-12, CSTA, CSEC2017, DigComp) are sky blue." +#| fig.alt: !expr density_alt_text library(ggplot2) library(plotly) diff --git a/vignettes/getting-started.Rmd b/vignettes/getting-started.Rmd index f60d119..74785a0 100644 --- a/vignettes/getting-started.Rmd +++ b/vignettes/getting-started.Rmd @@ -20,14 +20,14 @@ knitr::opts_chunk$set( ## What cybedtools does -Eleven cybersecurity workforce and learning frameworks (NICE, DCWF, SFIA, ENISA ECSF, CyQUAL, the Canadian Cyber Security Skills Framework, Singapore's OTCCF, Cyber.org K-12, CSTA K-12 CS, ACM/IEEE CSEC2017, DigComp 2.2) expressed in a shared `cybed:` semantic schema. The package adds a comparison layer over existing frameworks, not a replacement for them. +Cybersecurity workforce and learning frameworks (NICE, DCWF, SFIA, ENISA ECSF, CyQUAL, the Canadian Cyber Security Skills Framework, Singapore's OTCCF, Cyber.org K-12, CSTA K-12 CS, ACM/IEEE CSEC2017, DigComp 3.0) expressed in a shared `cybed:` semantic schema. The package adds a comparison layer over existing frameworks, not a replacement for them. This vignette walks through the two ways to use it: install the package and run helpers against the small built-in demo graph, or clone the repository and run the full pipeline against staged framework sources. Three semantic abstractions carry the work: - **`cybed:Framework`**: a competency or learning framework. -- **`cybed:OrganizingUnit`**: the framework's top-level enumerated unit (work role, work profile, skill, grade-band x sub-concept cell, level x concept cell, Knowledge Area, competence area). The cross-framework abstract; queries against it reach all eleven frameworks. Frameworks where the unit is genuinely a work role, job role, or profile (NICE, DCWF, ENISA ECSF, CyQUAL, CCSSF, OTCCF) additionally subclass `cybed:Role`. +- **`cybed:OrganizingUnit`**: the framework's top-level enumerated unit (work role, work profile, skill, grade-band x sub-concept cell, level x concept cell, Knowledge Area, competence area). The cross-framework abstract; queries against it reach every framework in the corpus. Frameworks where the unit is genuinely a work role, job role, or profile (NICE, DCWF, ENISA ECSF, CyQUAL, CCSSF, OTCCF) additionally subclass `cybed:Role`. - **`cybed:RoleElement`**: the codable atomic statement attached to an organizing unit (task, knowledge statement, skill statement, learning standard). Two specialized subtypes for parsed sub-content: `cybed:Subpoint` for framework-as-specified enumerations, and `cybed:Example` for pedagogical-scaffolding fragments lifted from "Clarification statement:" prose. A single SPARQL query targeting `cybed:RoleElement` returns atomic elements across every framework in one pass; targeting `cybed:OrganizingUnit` returns parents. @@ -52,7 +52,7 @@ flowchart TD src["Framework source files
NICE CPRT, DCWF XLSX, SFIA SQLite, ECSF JSON, ..."] ingest["010-ingest-*.R"] csv["Per-framework CSVs
+ provenance.yml"] - verify["015-verify-ingestion.R
six invariants"] + verify["015-verify-ingestion.R
invariant checks"] assemble["020-assemble-jsonld.R"] jsonld["JSON-LD
per-framework + combined"] ntriples["025-export-ntriples.R"] @@ -97,7 +97,7 @@ Before any downstream analysis, verify the ingested data against declared invari Rscript scripts/015-verify-ingestion.R ``` -The verifier checks six invariant layers: source provenance (SHA256 match), extraction count bounds, referential integrity, text-integrity (UTF-8 validity, non-empty, length sanity), ID uniqueness, and audit trail. Hard failures block downstream scripts. Soft flags warn but allow continuation. +The verifier checks several invariant layers: source provenance (SHA256 match), extraction count bounds, referential integrity, text-integrity (UTF-8 validity, non-empty, length sanity), ID uniqueness, and audit trail. Hard failures block downstream scripts. Soft flags warn but allow continuation. ```{r} #| eval: false @@ -127,9 +127,9 @@ Each document uses the two-tier namespace architecture: ## Running the analytical queries -The package's six named analyses are implemented in R, not as `.rq` files. They use single-BGP SPARQL primitives (`sparql_pairs()`, `sparql_subjects()`) composed via dplyr. See the `cross-framework-analysis` vignette for the full design rationale and the helper functions exposed. +The package's named analyses are implemented in R, not as `.rq` files. They use single-BGP SPARQL primitives (`sparql_pairs()`, `sparql_subjects()`) composed via dplyr. See the `cross-framework-analysis` vignette for the full design rationale and the helper functions exposed. -Run all six against the combined graph: +Run all of them against the combined graph: ```{bash} #| eval: false @@ -138,8 +138,8 @@ Rscript scripts/040-run-sparql.R Each analysis writes one CSV to `data/processed/query-results/`: -- `q10-organizing-units-per-framework.csv`, cross-framework parent count (all eleven frameworks via `cybed:OrganizingUnit`). -- `q10b-roles-per-framework.csv`, role-restricted parent count (the six frameworks that assert `cybed:Role`). +- `q10-organizing-units-per-framework.csv`, cross-framework parent count (every framework via `cybed:OrganizingUnit`). +- `q10b-roles-per-framework.csv`, role-restricted parent count (the frameworks that assert `cybed:Role`). - `q11-elements-per-framework-strict.csv`, strict element count per framework (parents + Subpoints, Examples excluded). - `q11b-elements-per-framework-with-examples.csv`, inclusive element count per framework (parents + Subpoints + Examples). - `q12-framework-metadata.csv`, jurisdiction, sector, specificity per framework. @@ -216,7 +216,7 @@ This returns one row per framework with jurisdiction, sector, and specificity, t ## Next steps - See `vignette("cross-framework-analysis", package = "cybedtools")` for worked examples of structural and analytical queries across frameworks. -- See `vignette("adding-a-framework", package = "cybedtools")` for how to extend the package with a framework beyond the current eleven. +- See `vignette("adding-a-framework", package = "cybedtools")` for how to extend the package with a framework beyond the current corpus. - See the [namespace-architecture](https://ryanstraight.github.io/cybedtools/articles/namespace-architecture.html) article for the two-tier schema design. - See the [data-integrity](https://ryanstraight.github.io/cybedtools/articles/data-integrity.html) article for the verification contract. - See the [sparql-strategy](https://ryanstraight.github.io/cybedtools/articles/sparql-strategy.html) article for query design rationale.