Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions etc/test-data/cyclonedx/ai/test_structured_model_card.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
{
"bomFormat": "CycloneDX",
"components": [
{
"authors": [
{
"name": "nvidia"
}
],
"bom-ref": "pkg:huggingface/nvidia/canary-1b-v2@1.0",
"copyright": "NOASSERTION",
"description": "No description available",
"externalReferences": [
{
"type": "website",
"url": "https://huggingface.co/nvidia/canary-1b-v2"
}
],
"group": "nvidia",
"licenses": [
{
"license": {
"id": "unknown",
"url": "https://spdx.org/licenses/"
}
}
],
"manufacturer": {
"name": "nvidia",
"url": [
"https://huggingface.co/nvidia"
]
},
"modelCard": {
"modelParameters": {
"architectureFamily": "transformer",
"inputs": [
{
"format": "text"
}
],
"modelArchitecture": "canary-1b-v2ForCausalLM",
"outputs": [
{
"format": "generated-text"
}
],
"task": "text-generation",
"approach": {
"type": "supervised-learning"
}
},
"quantitativeAnalysis": {
"graphics": {}
},
"considerations": {
"technicalLimitations": [
"Limited to English text",
"Max 2048 tokens"
],
"ethicalConsiderations": [
{
"name": "Bias in training data",
"mitigationStrategy": "Regular auditing of model outputs"
},
{
"name": "Privacy risks from memorization"
}
],
"fairnessAssessments": [
{
"groupAtRisk": "Non-English speakers",
"benefits": "Improved accessibility",
"harms": "Lower accuracy for underrepresented languages",
"mitigationStrategy": "Multilingual fine-tuning planned"
}
],
"performanceTradeoffs": [
"Speed vs accuracy on long sequences",
"Memory usage scales quadratically"
],
"useCases": [
"Conversational AI",
"Document summarization"
],
"users": [
"Developers",
"Researchers"
]
}
},
"name": "canary-1b-v2",
"publisher": "nvidia",
"purl": "pkg:huggingface/nvidia/canary-1b-v2@1.0",
"supplier": {
"name": "nvidia",
"url": [
"https://huggingface.co/nvidia"
]
},
"type": "machine-learning-model",
"version": "1.0"
}
],
"dependencies": [
{
"dependsOn": [
"pkg:huggingface/nvidia/canary-1b-v2@1.0"
],
"ref": "pkg:generic/nvidia%2Fcanary-1b-v2@1.0"
}
],
"externalReferences": [
{
"type": "distribution",
"url": "https://huggingface.co/nvidia/canary-1b-v2"
}
],
"metadata": {
"component": {
"bom-ref": "pkg:generic/nvidia%2Fcanary-1b-v2@1.0",
"copyright": "NOASSERTION",
"description": "No description available",
"name": "canary-1b-v2",
"purl": "pkg:generic/nvidia%2Fcanary-1b-v2@1.0",
"type": "application",
"version": "1.0"
},
"properties": [
{
"name": "primaryPurpose",
"value": "automatic-speech-recognition"
},
{
"name": "suppliedBy",
"value": "nvidia"
},
{
"name": "typeOfModel",
"value": "transformer"
},
{
"name": "bomFormat",
"value": "CycloneDX"
},
{
"name": "specVersion",
"value": "1.6"
},
{
"name": "serialNumber",
"value": "urn:uuid:nvidia-canary-1b-v2"
},
{
"name": "version",
"value": "1.0.0"
},
{
"name": "primaryPurpose",
"value": "automatic-speech-recognition"
},
{
"name": "suppliedBy",
"value": "nvidia"
},
{
"name": "licenses",
"value": "cc-by-4.0"
},
{
"name": "limitation",
"value": "adhered to, & Mitigation | Transcripts and translations may be not 100% accurate"
},
{
"name": "safetyRiskAssessment",
"value": "</span>\n\nField | Response\n:---------------------------------------------------:|:----------------------------------\nModel Application(s) | Speech to Text Transcription\nDescribe the life critical impact | None\nUse Case Restrictions | Abide by [CC-BY-4, </span>\n\nField | Response\n:---------------------------------------------------------------------------------------------------|:---------------:\nParticipation considerations from adversely impacted groups [protected classes](https://www, | If a word is not trained in the language model and not presented in vocabulary, the word is not likely to be recognized, & Security, and Privacy Subcards [here](https://developer, | None\n\n## <span style=\"color:#b37800;\">Explainability:</span>\n\nField | Response\n:------------------------------------------------------------------------------------------------------:|:---------------------------------------------------------------------------------:\nIntended Domain | Speech to Text Transcription and Translation\nModel Type | Attention Encoder-Decoder\nIntended Users | This model is intended for developers, researchers, academics, and industries building conversational based applications"
},
{
"name": "typeOfModel",
"value": "transformer"
},
{
"name": "downloadLocation",
"value": "https://huggingface.co/nvidia/canary-1b-v2/tree/main"
},
{
"name": "external_references",
"value": "[{\"type\": \"website\", \"url\": \"https://huggingface.co/nvidia/canary-1b-v2\", \"comment\": \"Model repository\"}, {\"type\": \"distribution\", \"url\": \"https://huggingface.co/nvidia/canary-1b-v2/tree/main\", \"comment\": \"Model files\"}]"
}
],
"timestamp": "2025-09-26T10:07:22.695451Z",
"tools": {
"components": [
{
"bom-ref": "pkg:generic/aetheris-ai/aetheris-aibom-generator@1.0.0",
"manufacturer": {
"name": "Aetheris AI"
},
"name": "aetheris-aibom-generator",
"type": "application",
"version": "1.0"
}
]
}
},
"serialNumber": "urn:uuid:test-structured-fields",
"specVersion": "1.6",
"version": 1
}
119 changes: 112 additions & 7 deletions modules/ingestor/src/graph/sbom/common/machine_learning_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,118 @@ pub struct ModelCard {

impl From<&Component> for ModelCard {
fn from(c: &Component) -> Self {
let properties = Value::from(c.model_card.as_ref().and_then(|card| {
card.properties.as_ref().map(|v| {
v.iter()
.map(|p| (p.name.clone(), p.value.clone().into()))
.collect::<Map<_, _>>()
})
}));
let properties = match c.model_card.as_ref() {
Some(card) => {
let mut map = Map::new();

if let Some(params) = &card.model_parameters {
if let Some(task) = &params.task {
map.insert("primaryPurpose".to_string(), Value::String(task.clone()));
}
if let Some(approach) = &params.approach
&& let Some(type_val) = &approach.type_
{
map.insert("typeOfModel".to_string(), Value::String(type_val.clone()));
}
}

if let Some(considerations) = &card.considerations {
if let Some(limitations) = &considerations.technical_limitations
&& !limitations.is_empty()
{
map.insert(
"limitation".to_string(),
Value::String(limitations.join("; ")),
);
}

if let Some(risks) = &considerations.ethical_considerations
&& !risks.is_empty()
{
let arr: Vec<Value> = risks
.iter()
.map(|r| {
let mut obj = Map::new();
if let Some(name) = &r.name {
obj.insert("name".to_string(), Value::String(name.clone()));
}
Comment on lines +45 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: Consider skipping fully-empty risk objects when all fields are None

Right now each ethical_considerations entry becomes an object even when both name and mitigation_strategy are None, producing {} entries. This may be unexpected for consumers. Consider either dropping objects that end up empty or only inserting when name is set, whichever best matches your schema.

Suggested implementation:

                    if let Some(risks) = &considerations.ethical_considerations
                        && !risks.is_empty()
                    {
                        let arr: Vec<Value> = risks
                            .iter()
                            .filter_map(|r| {
                                let mut obj = Map::new();
                                if let Some(name) = &r.name {
                                    obj.insert("name".to_string(), Value::String(name.clone()));
                                }
                                if let Some(ms) = &r.mitigation_strategy {

To fully implement the behavior of skipping fully-empty risk objects, adjust the rest of the closure so that instead of unconditionally returning Value::Object(obj), it returns None when obj.is_empty():

if let Some(ms) = &r.mitigation_strategy {
    obj.insert("mitigation_strategy".to_string(), Value::String(ms.clone()));
}
if obj.is_empty() {
    None
} else {
    Some(Value::Object(obj))
}

This should replace the previous Value::Object(obj) expression at the end of the closure.

if let Some(ms) = &r.mitigation_strategy {
obj.insert(
"mitigationStrategy".to_string(),
Value::String(ms.clone()),
);
}
Value::Object(obj)
})
.collect();
map.insert("safetyRiskAssessment".to_string(), Value::Array(arr));
}

if let Some(assessments) = &considerations.fairness_assessments
&& !assessments.is_empty()
{
let arr: Vec<Value> = assessments
.iter()
.map(|a| {
let mut obj = Map::new();
if let Some(v) = &a.group_at_risk {
obj.insert("groupAtRisk".to_string(), Value::String(v.clone()));
}
if let Some(v) = &a.benefits {
obj.insert("benefits".to_string(), Value::String(v.clone()));
}
if let Some(v) = &a.harms {
obj.insert("harms".to_string(), Value::String(v.clone()));
}
if let Some(v) = &a.mitigation_strategy {
obj.insert(
"mitigationStrategy".to_string(),
Value::String(v.clone()),
);
}
Value::Object(obj)
})
.collect();
map.insert("fairnessAssessments".to_string(), Value::Array(arr));
}

if let Some(tradeoffs) = &considerations.performance_tradeoffs
&& !tradeoffs.is_empty()
{
map.insert(
"performanceTradeoffs".to_string(),
Value::String(tradeoffs.join("; ")),
);
}

if let Some(use_cases) = &considerations.use_cases
&& !use_cases.is_empty()
{
map.insert("useCases".to_string(), Value::String(use_cases.join("; ")));
}

if let Some(users) = &considerations.users
&& !users.is_empty()
{
map.insert("users".to_string(), Value::String(users.join("; ")));
}
}

// Generic properties take precedence over structured fields
if let Some(props) = &card.properties {
for p in props {
map.insert(p.name.clone(), p.value.clone().into());
}
}

if map.is_empty() {
Value::Null
Comment on lines +124 to +125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): Revisit behavior change for model cards with only empty generic properties

Previously, an empty card.properties vector resulted in Some(Map::new()) and thus Value::Object({}). With the new check, the same case now yields Value::Null. This changes observable behavior for model cards that exist but have no effective properties and may break callers that distinguish between an empty object and null. If that distinction matters, consider still returning Value::Object(map) when card.properties was Some(_), even if the map is empty.

} else {
Value::Object(map)
}
}
None => Value::Null,
};
ModelCard { properties }
}
}
Expand Down
Loading
Loading