Skip to content

Commit 470e8d7

Browse files
committed
feat(catalog): add loader for security-evaluations.ndjson
Extends processModelArtifactsBatch() to parse security-evaluations.ndjson files from the existing performance metrics directories, creating artifacts with metricsType: security-metrics. No new config flags or dependencies required. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Paul Boyd <paul@pboyd.io>
1 parent 3615014 commit 470e8d7

3 files changed

Lines changed: 650 additions & 6 deletions

File tree

catalog/internal/catalog/modelcatalog/performance_metrics.go

Lines changed: 189 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@ import (
2121
// metadataJSON represents the minimal structure needed from metadata.json files
2222
// Only the ID field is needed to look up existing models
2323
type metadataJSON struct {
24-
ID string `json:"id"` // Maps to model name for lookup
25-
OverallAccuracy *float64 `json:"overall_accuracy"` // Overall accuracy score for the model
26-
Size *string `json:"size"` // Model parameter count (e.g., "8B params")
27-
TensorType *string `json:"tensor_type"` // Data precision (e.g., "FP16", "INT4")
28-
VariantGroupID *string `json:"variant_group_id"` // UUID linking model variants together
24+
ID string `json:"id"` // Maps to model name for lookup
25+
OverallAccuracy *float64 `json:"overall_accuracy"` // Overall accuracy score for the model
26+
Size *string `json:"size"` // Model parameter count (e.g., "8B params")
27+
TensorType *string `json:"tensor_type"` // Data precision (e.g., "FP16", "INT4")
28+
VariantGroupID *string `json:"variant_group_id"` // UUID linking model variants together
2929
MinVRAMGB *float64 `json:"min_vram_gb"` // Minimum VRAM required in GB (e.g., 466.0)
3030
ModelcarImageSize *float64 `json:"modelcar_image_size"` // Modelcar image size in GB (e.g., 405.19)
3131
ModelcarImageSizeBytes *int64 `json:"modelcar_image_size_bytes"` // Modelcar image size in bytes (e.g., 405186009411)
@@ -137,6 +137,41 @@ func (pr *performanceRecord) UnmarshalJSON(data []byte) error {
137137
return nil
138138
}
139139

140+
// securityEvaluationRecord represents a single security evaluation result from security-evaluations.ndjson
141+
type securityEvaluationRecord struct {
142+
// Core fields needed to associate security data with model
143+
ID string `json:"id"`
144+
ModelID string `json:"model_id"`
145+
146+
// CustomProperties captures remaining fields dynamically
147+
CustomProperties map[string]any `json:"-"`
148+
}
149+
150+
// UnmarshalJSON implements custom JSON unmarshaling to capture all undefined fields as CustomProperties
151+
func (sr *securityEvaluationRecord) UnmarshalJSON(data []byte) error {
152+
var raw map[string]any
153+
decoder := json.NewDecoder(bytes.NewReader(data))
154+
decoder.UseNumber()
155+
if err := decoder.Decode(&raw); err != nil {
156+
return err
157+
}
158+
159+
if id, ok := raw["id"].(string); ok {
160+
sr.ID = id
161+
}
162+
if modelID, ok := raw["model_id"].(string); ok {
163+
sr.ModelID = modelID
164+
}
165+
166+
if sr.CustomProperties == nil {
167+
sr.CustomProperties = make(map[string]any)
168+
}
169+
170+
maps.Copy(sr.CustomProperties, raw)
171+
172+
return nil
173+
}
174+
140175
type PerformanceMetricsLoader struct {
141176
path []string
142177
modelRepo dbmodels.CatalogModelRepository
@@ -334,6 +369,7 @@ func processModelArtifactsBatch(dirPath string, modelID int32, modelName string,
334369
// Parse all metrics files
335370
var evaluationRecords []evaluationRecord
336371
var performanceRecords []performanceRecord
372+
var securityRecords []securityEvaluationRecord
337373

338374
// Parse evaluation metrics if file exists
339375
evaluationsPath := filepath.Join(dirPath, "evaluations.ndjson")
@@ -357,7 +393,18 @@ func processModelArtifactsBatch(dirPath string, modelID int32, modelName string,
357393
}
358394
}
359395

360-
totalRecords := len(evaluationRecords) + len(performanceRecords) + len(coldStartMatrix)
396+
// Parse security evaluation metrics if file exists
397+
securityPath := filepath.Join(dirPath, "security-evaluations.ndjson")
398+
if _, err := os.Stat(securityPath); err == nil {
399+
records, err := parseSecurityEvaluationFile(securityPath)
400+
if err != nil {
401+
glog.Errorf("Failed to parse security evaluations file for %s: %v", modelName, err)
402+
} else {
403+
securityRecords = records
404+
}
405+
}
406+
407+
totalRecords := len(evaluationRecords) + len(performanceRecords) + len(securityRecords) + len(coldStartMatrix)
361408
if totalRecords == 0 {
362409
return 0, nil
363410
}
@@ -418,6 +465,22 @@ func processModelArtifactsBatch(dirPath string, modelID int32, modelName string,
418465
}
419466
}
420467

468+
// Check security evaluation artifacts; deduplicate within the file before the DB check
469+
seenSecurityIDs := make(map[string]bool, len(securityRecords))
470+
for _, secRecord := range securityRecords {
471+
if seenSecurityIDs[secRecord.ID] {
472+
glog.Warningf("Duplicate security artifact ID %s in file, skipping", secRecord.ID)
473+
continue
474+
}
475+
seenSecurityIDs[secRecord.ID] = true
476+
if !existingArtifactsMap[secRecord.ID] {
477+
artifact := createSecurityArtifact(secRecord, modelID, metricsArtifactTypeID, nil, nil)
478+
artifactsToInsert = append(artifactsToInsert, artifact)
479+
} else {
480+
glog.V(2).Infof("Security artifact %s already exists, skipping", secRecord.ID)
481+
}
482+
}
483+
421484
if len(artifactsToInsert) == 0 {
422485
glog.V(2).Infof("All artifacts already exist for model %s, nothing to insert", modelName)
423486
return 0, nil
@@ -728,6 +791,126 @@ func createColdStartArtifact(entry coldStartEntry, externalID string, typeID int
728791
}
729792
}
730793

794+
// parseSecurityEvaluationFile reads and parses a security-evaluations.ndjson file
795+
func parseSecurityEvaluationFile(filePath string) ([]securityEvaluationRecord, error) {
796+
file, err := os.Open(filePath)
797+
if err != nil {
798+
return nil, fmt.Errorf("failed to open security evaluation file %s: %v", filePath, err)
799+
}
800+
defer file.Close()
801+
802+
scanner := bufio.NewScanner(file)
803+
securityRecords := []securityEvaluationRecord{}
804+
805+
for scanner.Scan() {
806+
line := scanner.Text()
807+
if strings.TrimSpace(line) == "" {
808+
continue
809+
}
810+
811+
var secRecord securityEvaluationRecord
812+
if err := json.Unmarshal([]byte(line), &secRecord); err != nil {
813+
glog.Errorf("Failed to parse security evaluation record: %v", err)
814+
continue
815+
}
816+
817+
securityRecords = append(securityRecords, secRecord)
818+
}
819+
820+
if err := scanner.Err(); err != nil {
821+
return nil, fmt.Errorf("error reading security evaluation file: %v", err)
822+
}
823+
824+
return securityRecords, nil
825+
}
826+
827+
// createSecurityArtifact creates a metrics artifact from a security evaluation record
828+
func createSecurityArtifact(secRecord securityEvaluationRecord, modelID int32, typeID int32, existingID *int32, existingCreateTime *int64) *dbmodels.CatalogMetricsArtifactImpl {
829+
artifactName := fmt.Sprintf("security-%s", secRecord.ID)
830+
831+
createTime := existingCreateTime
832+
var updateTime *int64
833+
834+
if existingCreateTime == nil {
835+
if createdAtNum, ok := secRecord.CustomProperties["created_at"].(json.Number); ok {
836+
createdAt, err := createdAtNum.Int64()
837+
if err == nil {
838+
createTime = &createdAt
839+
} else {
840+
glog.Warningf("%s: invalid created_at value: %v", artifactName, err)
841+
}
842+
}
843+
}
844+
if createTime == nil {
845+
createTime = new(time.Now().UnixMilli())
846+
}
847+
848+
if updatedAtNum, ok := secRecord.CustomProperties["updated_at"].(json.Number); ok {
849+
updatedAt, err := updatedAtNum.Int64()
850+
if err == nil {
851+
updateTime = &updatedAt
852+
} else {
853+
glog.Warningf("%s: invalid updated_at value: %v", artifactName, err)
854+
}
855+
}
856+
if updateTime == nil {
857+
updateTime = new(time.Now().UnixMilli())
858+
}
859+
delete(secRecord.CustomProperties, "updated_at")
860+
delete(secRecord.CustomProperties, "created_at")
861+
862+
properties := []models.Properties{}
863+
customProperties := []models.Properties{}
864+
865+
for key, value := range secRecord.CustomProperties {
866+
prop := models.Properties{Name: key}
867+
868+
switch v := value.(type) {
869+
case string:
870+
prop.StringValue = &v
871+
case float64:
872+
prop.DoubleValue = &v
873+
case int64:
874+
prop.SetInt64Value(v)
875+
case int:
876+
intVal := int32(v)
877+
prop.IntValue = &intVal
878+
case bool:
879+
prop.BoolValue = &v
880+
case json.Number:
881+
if n, err := v.Int64(); err == nil {
882+
prop.SetInt64Value(n)
883+
} else if f, err := v.Float64(); err == nil {
884+
prop.DoubleValue = &f
885+
} else {
886+
strVal := v.String()
887+
prop.StringValue = &strVal
888+
}
889+
default:
890+
strVal := fmt.Sprintf("%v", v)
891+
prop.StringValue = &strVal
892+
}
893+
894+
customProperties = append(customProperties, prop)
895+
}
896+
897+
metricsArtifact := &dbmodels.CatalogMetricsArtifactImpl{
898+
ID: existingID,
899+
TypeID: &typeID,
900+
Attributes: &dbmodels.CatalogMetricsArtifactAttributes{
901+
Name: &artifactName,
902+
ExternalID: &secRecord.ID,
903+
CreateTimeSinceEpoch: createTime,
904+
LastUpdateTimeSinceEpoch: updateTime,
905+
MetricsType: dbmodels.MetricsTypeSecurityMetrics,
906+
},
907+
Properties: &properties,
908+
CustomProperties: &customProperties,
909+
}
910+
911+
return metricsArtifact
912+
}
913+
731914
// enrichCatalogModelFromMetadata updates CatalogModel with additional fields from metadata.json
732915
func enrichCatalogModelFromMetadata(existingModel dbmodels.CatalogModel, metadata metadataJSON, modelRepo dbmodels.CatalogModelRepository) error {
733916
// Build custom properties to add/update

0 commit comments

Comments
 (0)