weight-audit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HiroCheck
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: weight-audit
3
+ Version: 0.1.0
4
+ Summary: License compliance scanner for open-weight AI models (Llama, Gemma, Qwen, DeepSeek, and more)
5
+ Author: HiroCheck
6
+ License: MIT
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Programming Language :: Python :: 3
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # weight-audit
15
+
16
+ Scan the open-weight AI models a project depends on (Llama, Gemma, Qwen,
17
+ DeepSeek, and others) for license compliance risk before a scale-cap or
18
+ acceptable-use restriction turns into a legal problem for a commercial
19
+ product.
20
+
21
+ ## Why
22
+
23
+ Open-weight models are not all "open source." The Llama Community
24
+ License caps free commercial use at 700M monthly active users. Gemma,
25
+ Qwen, and DeepSeek carry acceptable-use policies. CC-BY-NC weights
26
+ forbid commercial use outright. Roughly 70% of models on Hugging Face
27
+ ship with no license metadata at all. Enterprise SCA platforms
28
+ (Endor Labs, Sonatype, Black Duck) now gate on this for large
29
+ customers -- this tool does the same check for the developers and
30
+ small AI startups those platforms don't sell to.
31
+
32
+ ## Install
33
+
34
+ ```
35
+ pip install weight-audit
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ```
41
+ weight-audit scan . # scan a directory (manifest + source detection)
42
+ weight-audit scan . --policy policy.json # use a custom policy
43
+ weight-audit scan . --json # machine-readable output for CI
44
+ weight-audit scan . --online # also query the HF Hub API for models not in the offline DB
45
+ weight-audit bom . --output ml-bom.json # generate a CycloneDX ML-BOM
46
+ ```
47
+
48
+ Exit code is `1` if any model violates the policy, `0` otherwise --
49
+ safe to drop into CI:
50
+
51
+ ```
52
+ - run: weight-audit scan .
53
+ ```
54
+
55
+ ### Detecting models
56
+
57
+ weight-audit looks for models in two places:
58
+
59
+ 1. A `weight-audit-models.txt` manifest in the scanned directory --
60
+ one Hugging Face model id per line, with an optional declared
61
+ license as a second column:
62
+ ```
63
+ meta-llama/Llama-3-8B-Instruct
64
+ some-org/attributed-model,cc-by-4.0
65
+ ```
66
+ 2. A best-effort scan of `.py` source for
67
+ `AutoModel.from_pretrained("org/model")` and `model_id = "org/model"`
68
+ patterns. This is a convenience for a first scan -- for reliable CI
69
+ gating, use a manifest.
70
+
71
+ ### Policy
72
+
73
+ By default, anything at `strong-restriction` or above is a violation,
74
+ and models with no license metadata at all (`unknown`) are treated as
75
+ violations too. Override with a JSON file:
76
+
77
+ ```json
78
+ {
79
+ "fail_at_or_above": "weak-restriction",
80
+ "treat_unknown_as_violation": false
81
+ }
82
+ ```
83
+
84
+ ## How it classifies
85
+
86
+ Licenses are bucketed into four tiers: `permissive` < `weak-restriction`
87
+ < `strong-restriction` < `unknown`. 27 named model families (Llama,
88
+ Gemma, Qwen, DeepSeek, BLOOM, StarCoder, Baichuan, ChatGLM, Yi, Grok,
89
+ FLUX, and others) are matched against a hand-curated offline database
90
+ recording their *actual* restriction -- not just the license name --
91
+ so a scale cap or acceptable-use policy shows up as a concrete
92
+ obligation, not just a label. See `weight_audit/license_db.py` for the
93
+ exact rules and `weight_audit/classify.py` for the resolution order.
94
+
95
+ A broader SPDX/common-identifier fallback table (Apache-2.0, MIT,
96
+ CC-BY variants, OpenRAIL variants, and named Llama/Gemma license
97
+ strings) covers models outside the named-family list when a license
98
+ string is available, either from a manifest's `declared_license`
99
+ column or from `--online`.
100
+
101
+ ### Online mode
102
+
103
+ `--online` queries the Hugging Face Hub API for models that aren't in
104
+ the offline database and don't have a declared license in the
105
+ manifest. It never overrides a license already recorded in the
106
+ manifest, and any network failure (timeout, 404, malformed response)
107
+ degrades to `unknown` rather than crashing the scan -- the offline
108
+ database remains the reliable default; `--online` only fills gaps.
109
+
110
+ ## Limitations
111
+
112
+ The offline database is a hand-curated table of well-known model
113
+ families, not a registry mirror. Models outside the known families and
114
+ without a declared license (from a manifest or `--online`) are flagged
115
+ `unknown`. This is deliberate: "unknown" surfaces the real state of the
116
+ ecosystem (~70% of Hugging Face models carry no license) rather than
117
+ silently passing them.
118
+
119
+ ## License
120
+
121
+ MIT
122
+
123
+ ## Support
124
+
125
+ If this project is useful to you, you can support development here:
126
+ <https://buy.stripe.com/PLACEHOLDER>
@@ -0,0 +1,113 @@
1
+ # weight-audit
2
+
3
+ Scan the open-weight AI models a project depends on (Llama, Gemma, Qwen,
4
+ DeepSeek, and others) for license compliance risk before a scale-cap or
5
+ acceptable-use restriction turns into a legal problem for a commercial
6
+ product.
7
+
8
+ ## Why
9
+
10
+ Open-weight models are not all "open source." The Llama Community
11
+ License caps free commercial use at 700M monthly active users. Gemma,
12
+ Qwen, and DeepSeek carry acceptable-use policies. CC-BY-NC weights
13
+ forbid commercial use outright. Roughly 70% of models on Hugging Face
14
+ ship with no license metadata at all. Enterprise SCA platforms
15
+ (Endor Labs, Sonatype, Black Duck) now gate on this for large
16
+ customers -- this tool does the same check for the developers and
17
+ small AI startups those platforms don't sell to.
18
+
19
+ ## Install
20
+
21
+ ```
22
+ pip install weight-audit
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```
28
+ weight-audit scan . # scan a directory (manifest + source detection)
29
+ weight-audit scan . --policy policy.json # use a custom policy
30
+ weight-audit scan . --json # machine-readable output for CI
31
+ weight-audit scan . --online # also query the HF Hub API for models not in the offline DB
32
+ weight-audit bom . --output ml-bom.json # generate a CycloneDX ML-BOM
33
+ ```
34
+
35
+ Exit code is `1` if any model violates the policy, `0` otherwise --
36
+ safe to drop into CI:
37
+
38
+ ```
39
+ - run: weight-audit scan .
40
+ ```
41
+
42
+ ### Detecting models
43
+
44
+ weight-audit looks for models in two places:
45
+
46
+ 1. A `weight-audit-models.txt` manifest in the scanned directory --
47
+ one Hugging Face model id per line, with an optional declared
48
+ license as a second column:
49
+ ```
50
+ meta-llama/Llama-3-8B-Instruct
51
+ some-org/attributed-model,cc-by-4.0
52
+ ```
53
+ 2. A best-effort scan of `.py` source for
54
+ `AutoModel.from_pretrained("org/model")` and `model_id = "org/model"`
55
+ patterns. This is a convenience for a first scan -- for reliable CI
56
+ gating, use a manifest.
57
+
58
+ ### Policy
59
+
60
+ By default, anything at `strong-restriction` or above is a violation,
61
+ and models with no license metadata at all (`unknown`) are treated as
62
+ violations too. Override with a JSON file:
63
+
64
+ ```json
65
+ {
66
+ "fail_at_or_above": "weak-restriction",
67
+ "treat_unknown_as_violation": false
68
+ }
69
+ ```
70
+
71
+ ## How it classifies
72
+
73
+ Licenses are bucketed into four tiers: `permissive` < `weak-restriction`
74
+ < `strong-restriction` < `unknown`. 27 named model families (Llama,
75
+ Gemma, Qwen, DeepSeek, BLOOM, StarCoder, Baichuan, ChatGLM, Yi, Grok,
76
+ FLUX, and others) are matched against a hand-curated offline database
77
+ recording their *actual* restriction -- not just the license name --
78
+ so a scale cap or acceptable-use policy shows up as a concrete
79
+ obligation, not just a label. See `weight_audit/license_db.py` for the
80
+ exact rules and `weight_audit/classify.py` for the resolution order.
81
+
82
+ A broader SPDX/common-identifier fallback table (Apache-2.0, MIT,
83
+ CC-BY variants, OpenRAIL variants, and named Llama/Gemma license
84
+ strings) covers models outside the named-family list when a license
85
+ string is available, either from a manifest's `declared_license`
86
+ column or from `--online`.
87
+
88
+ ### Online mode
89
+
90
+ `--online` queries the Hugging Face Hub API for models that aren't in
91
+ the offline database and don't have a declared license in the
92
+ manifest. It never overrides a license already recorded in the
93
+ manifest, and any network failure (timeout, 404, malformed response)
94
+ degrades to `unknown` rather than crashing the scan -- the offline
95
+ database remains the reliable default; `--online` only fills gaps.
96
+
97
+ ## Limitations
98
+
99
+ The offline database is a hand-curated table of well-known model
100
+ families, not a registry mirror. Models outside the known families and
101
+ without a declared license (from a manifest or `--online`) are flagged
102
+ `unknown`. This is deliberate: "unknown" surfaces the real state of the
103
+ ecosystem (~70% of Hugging Face models carry no license) rather than
104
+ silently passing them.
105
+
106
+ ## License
107
+
108
+ MIT
109
+
110
+ ## Support
111
+
112
+ If this project is useful to you, you can support development here:
113
+ <https://buy.stripe.com/PLACEHOLDER>
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "weight-audit"
7
+ version = "0.1.0"
8
+ description = "License compliance scanner for open-weight AI models (Llama, Gemma, Qwen, DeepSeek, and more)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "HiroCheck"}]
13
+ classifiers = [
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ ]
17
+
18
+ [project.scripts]
19
+ weight-audit = "weight_audit.cli:main"
20
+
21
+ [tool.setuptools.packages.find]
22
+ include = ["weight_audit*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,79 @@
1
+ """Accuracy harness: known-violating and known-clean fixtures.
2
+
3
+ Mirrors license-radar's tests/test_parsers.py + accuracy pattern and
4
+ gha-audit's tests/test_accuracy.py: every fixture directory is a
5
+ manifest scan, and we assert the policy verdict is correct given the
6
+ *default* policy (fail_at_or_above=strong-restriction,
7
+ treat_unknown_as_violation=True).
8
+
9
+ Recall = violating fixtures correctly flagged / total violating fixtures
10
+ FP rate = clean fixtures incorrectly flagged / total clean fixtures
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ from weight_audit.classify import classify
20
+ from weight_audit.policy import evaluate, load_policy
21
+ from weight_audit.scanner import scan
22
+
23
+ FIXTURES_DIR = Path(__file__).parent / "fixtures"
24
+
25
+ VIOLATING_FIXTURES = [
26
+ "violating-llama",
27
+ "violating-gemma",
28
+ "violating-qwen",
29
+ "violating-deepseek",
30
+ "violating-stability",
31
+ "violating-unknown",
32
+ "violating-cc-by-nc",
33
+ "source-detect-violating",
34
+ "violating-bloom",
35
+ "violating-starcoder",
36
+ "violating-baichuan",
37
+ ]
38
+
39
+ CLEAN_FIXTURES = [
40
+ "safe-mistral",
41
+ "safe-phi",
42
+ "safe-falcon",
43
+ "safe-cc-by",
44
+ "safe-mixed",
45
+ "source-detect-safe",
46
+ "safe-mpt",
47
+ "safe-olmo",
48
+ ]
49
+
50
+
51
+ def _scan_fixture(name: str) -> bool:
52
+ """Return True if the fixture's policy evaluation flags a violation."""
53
+ refs = scan(FIXTURES_DIR / name)
54
+ assert refs, f"fixture {name} produced no model references -- fixture is broken"
55
+ verdicts = [classify(r.model_id, r.declared_license) for r in refs]
56
+ policy = load_policy(None)
57
+ results = evaluate(verdicts, policy)
58
+ return any(r.violates for r in results)
59
+
60
+
61
+ @pytest.mark.parametrize("name", VIOLATING_FIXTURES)
62
+ def test_violating_fixtures_are_flagged(name: str) -> None:
63
+ assert _scan_fixture(name) is True, f"{name} should violate the default policy but did not"
64
+
65
+
66
+ @pytest.mark.parametrize("name", CLEAN_FIXTURES)
67
+ def test_clean_fixtures_are_not_flagged(name: str) -> None:
68
+ assert _scan_fixture(name) is False, f"{name} should NOT violate the default policy but did"
69
+
70
+
71
+ def test_accuracy_summary(capsys) -> None:
72
+ """Not a real assertion -- prints recall / FP rate for visibility,
73
+ matching license-radar and gha-audit's accuracy reporting."""
74
+ tp = sum(1 for name in VIOLATING_FIXTURES if _scan_fixture(name))
75
+ fp = sum(1 for name in CLEAN_FIXTURES if _scan_fixture(name))
76
+ recall = tp / len(VIOLATING_FIXTURES)
77
+ fp_rate = fp / len(CLEAN_FIXTURES)
78
+ print(f"\nRecall: {recall:.1%} ({tp}/{len(VIOLATING_FIXTURES)})")
79
+ print(f"False-positive rate: {fp_rate:.1%} ({fp}/{len(CLEAN_FIXTURES)})")
@@ -0,0 +1,76 @@
1
+ from weight_audit.classify import Tier, classify
2
+
3
+
4
+ def test_llama_is_strong_restriction():
5
+ v = classify("meta-llama/Llama-3-8B-Instruct")
6
+ assert v.tier == Tier.STRONG_RESTRICTION
7
+ assert "700M" in v.obligation
8
+
9
+
10
+ def test_mistral_is_permissive():
11
+ v = classify("mistralai/Mistral-7B-v0.1")
12
+ assert v.tier == Tier.PERMISSIVE
13
+
14
+
15
+ def test_unknown_model_is_unknown_tier():
16
+ v = classify("some-rando/never-heard-of-it")
17
+ assert v.tier == Tier.UNKNOWN
18
+
19
+
20
+ def test_declared_cc_by_nc_overrides_to_strong_restriction():
21
+ v = classify("some-org/whatever-model", declared_license="CC-BY-NC-4.0")
22
+ assert v.tier == Tier.STRONG_RESTRICTION
23
+
24
+
25
+ def test_declared_cc_by_is_weak_restriction():
26
+ v = classify("some-org/whatever-model", declared_license="cc-by-4.0")
27
+ assert v.tier == Tier.WEAK_RESTRICTION
28
+
29
+
30
+ # --- expanded family coverage (added alongside --online support) ---
31
+
32
+ def test_bloom_is_strong_restriction():
33
+ v = classify("bigscience/bloom-7b1")
34
+ assert v.tier == Tier.STRONG_RESTRICTION
35
+
36
+
37
+ def test_starcoder_is_strong_restriction():
38
+ v = classify("bigcode/starcoder2-15b")
39
+ assert v.tier == Tier.STRONG_RESTRICTION
40
+
41
+
42
+ def test_mpt_is_permissive():
43
+ v = classify("mosaicml/mpt-7b")
44
+ assert v.tier == Tier.PERMISSIVE
45
+
46
+
47
+ def test_olmo_is_permissive():
48
+ v = classify("allenai/olmo-7b")
49
+ assert v.tier == Tier.PERMISSIVE
50
+
51
+
52
+ def test_baichuan_is_strong_restriction():
53
+ v = classify("baichuan-inc/baichuan2-13b")
54
+ assert v.tier == Tier.STRONG_RESTRICTION
55
+
56
+
57
+ def test_yi_is_strong_restriction():
58
+ v = classify("01-ai/yi-34b")
59
+ assert v.tier == Tier.STRONG_RESTRICTION
60
+
61
+
62
+ def test_flux_schnell_is_permissive_but_dev_is_restricted():
63
+ schnell = classify("black-forest-labs/flux.1-schnell")
64
+ dev = classify("black-forest-labs/flux.1-dev")
65
+ assert schnell.tier == Tier.PERMISSIVE
66
+ assert dev.tier == Tier.STRONG_RESTRICTION
67
+
68
+
69
+ def test_declared_llama3_1_string_is_strong_restriction():
70
+ v = classify("some-mirror/repackaged-model", declared_license="llama3.1")
71
+ assert v.tier == Tier.STRONG_RESTRICTION
72
+
73
+
74
+ def test_declared_cc_by_sa_is_weak_restriction():
75
+ v = classify("some-org/whatever-model", declared_license="cc-by-sa-4.0")
76
+ assert v.tier == Tier.WEAK_RESTRICTION
@@ -0,0 +1,53 @@
1
+ from pathlib import Path
2
+
3
+ import weight_audit.cli as cli_module
4
+ from weight_audit.cli import main
5
+
6
+
7
+ def test_online_flag_resolves_unknown_model_via_mocked_hf(tmp_path: Path, monkeypatch, capsys):
8
+ # A manifest referencing a model with NO declared_license column,
9
+ # and NOT in the offline family table -- would be "unknown" offline.
10
+ manifest = tmp_path / "weight-audit-models.txt"
11
+ manifest.write_text("some-org/not-in-offline-db\n", encoding="utf-8")
12
+
13
+ def fake_fetch(model_id, fetch=None):
14
+ assert model_id == "some-org/not-in-offline-db"
15
+ return "cc-by-nc-4.0"
16
+
17
+ monkeypatch.setattr(cli_module, "fetch_declared_license", fake_fetch)
18
+
19
+ exit_code = main(["scan", str(tmp_path), "--online", "--json"])
20
+ out = capsys.readouterr().out
21
+ assert exit_code == 1 # cc-by-nc-4.0 -> strong-restriction -> violates default policy
22
+ assert "cc-by-nc-4.0" in out
23
+ assert "strong-restriction" in out
24
+
25
+
26
+ def test_without_online_flag_unknown_model_stays_unknown(tmp_path: Path, monkeypatch, capsys):
27
+ manifest = tmp_path / "weight-audit-models.txt"
28
+ manifest.write_text("some-org/not-in-offline-db\n", encoding="utf-8")
29
+
30
+ def fail_if_called(model_id, fetch=None):
31
+ raise AssertionError("fetch_declared_license should not be called without --online")
32
+
33
+ monkeypatch.setattr(cli_module, "fetch_declared_license", fail_if_called)
34
+
35
+ exit_code = main(["scan", str(tmp_path), "--json"])
36
+ out = capsys.readouterr().out
37
+ assert exit_code == 1 # unknown -> violates by default (treat_unknown_as_violation)
38
+ assert "\"tier\": \"unknown\"" in out
39
+
40
+
41
+ def test_manifest_declared_license_is_not_overwritten_by_online(tmp_path: Path, monkeypatch, capsys):
42
+ manifest = tmp_path / "weight-audit-models.txt"
43
+ manifest.write_text("some-org/has-declared-license,apache-2.0\n", encoding="utf-8")
44
+
45
+ def fail_if_called(model_id, fetch=None):
46
+ raise AssertionError("should not call the network when manifest already has a declared_license")
47
+
48
+ monkeypatch.setattr(cli_module, "fetch_declared_license", fail_if_called)
49
+
50
+ exit_code = main(["scan", str(tmp_path), "--online", "--json"])
51
+ out = capsys.readouterr().out
52
+ assert exit_code == 0
53
+ assert "apache-2.0" in out
@@ -0,0 +1,62 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from weight_audit.hf_client import fetch_declared_license
6
+ import urllib.error
7
+
8
+
9
+ def _fake_fetch_cardData(url: str) -> bytes:
10
+ return json.dumps({"cardData": {"license": "apache-2.0"}}).encode()
11
+
12
+
13
+ def _fake_fetch_top_level_license(url: str) -> bytes:
14
+ return json.dumps({"license": "mit"}).encode()
15
+
16
+
17
+ def _fake_fetch_tag_only(url: str) -> bytes:
18
+ return json.dumps({"tags": ["pytorch", "license:cc-by-4.0", "text-generation"]}).encode()
19
+
20
+
21
+ def _fake_fetch_no_license(url: str) -> bytes:
22
+ return json.dumps({"tags": ["pytorch"]}).encode()
23
+
24
+
25
+ def _fake_fetch_malformed(url: str) -> bytes:
26
+ return b"not json{{{"
27
+
28
+
29
+ def _fake_fetch_404(url: str) -> bytes:
30
+ raise urllib.error.HTTPError(url, 404, "Not Found", hdrs=None, fp=None) # type: ignore[arg-type]
31
+
32
+
33
+ def _fake_fetch_network_error(url: str) -> bytes:
34
+ raise urllib.error.URLError("connection refused")
35
+
36
+
37
+ def test_reads_cardData_license():
38
+ assert fetch_declared_license("org/model", fetch=_fake_fetch_cardData) == "apache-2.0"
39
+
40
+
41
+ def test_reads_top_level_license():
42
+ assert fetch_declared_license("org/model", fetch=_fake_fetch_top_level_license) == "mit"
43
+
44
+
45
+ def test_reads_license_tag():
46
+ assert fetch_declared_license("org/model", fetch=_fake_fetch_tag_only) == "cc-by-4.0"
47
+
48
+
49
+ def test_returns_none_when_no_license_present():
50
+ assert fetch_declared_license("org/model", fetch=_fake_fetch_no_license) is None
51
+
52
+
53
+ def test_returns_none_on_malformed_response():
54
+ assert fetch_declared_license("org/model", fetch=_fake_fetch_malformed) is None
55
+
56
+
57
+ def test_returns_none_on_404():
58
+ assert fetch_declared_license("org/model", fetch=_fake_fetch_404) is None
59
+
60
+
61
+ def test_returns_none_on_network_error():
62
+ assert fetch_declared_license("org/model", fetch=_fake_fetch_network_error) is None
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,92 @@
1
+ """License classification for open-weight AI models.
2
+
3
+ Mirrors license-radar's classify.py pattern: normalize to a known
4
+ identifier, bucket into a severity tier, and record the *actual*
5
+ restriction (not just the license name) so policy checks can act on it.
6
+
7
+ The data (named model families, declared-license lookup table) lives
8
+ in license_db.py so the ruleset can grow independently of this
9
+ control-flow logic.
10
+
11
+ Tiers (least to most restrictive), matching license-radar's naming
12
+ convention so users of both tools share a mental model:
13
+
14
+ permissive -- no meaningful restriction on commercial use
15
+ weak-restriction -- attribution / redistribution conditions only
16
+ strong-restriction -- usage caps, acceptable-use policies, or
17
+ non-commercial-only terms
18
+ unknown -- no license metadata found at all
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+
25
+ from .license_db import DECLARED_LICENSE_TIERS, KNOWN_FAMILIES, NON_COMMERCIAL_MARKERS
26
+ from .tiers import Tier
27
+
28
+ __all__ = ["Tier", "LicenseVerdict", "classify"]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class LicenseVerdict:
33
+ model_id: str
34
+ license_id: str
35
+ tier: Tier
36
+ obligation: str # human-readable summary of the actual restriction
37
+
38
+
39
+ def classify(model_id: str, declared_license: str | None = None) -> LicenseVerdict:
40
+ """Classify a model id (+ optional declared license string from
41
+ the registry's metadata, e.g. from a manifest or --online lookup)
42
+ into a tier and a concrete obligation.
43
+
44
+ Resolution order:
45
+ 1. Non-commercial marker in the declared license (unambiguous,
46
+ declared consistently across unrelated orgs).
47
+ 2. Named model family (org/model prefix match) -- the highest-value
48
+ path, since it records the *actual* restriction, not just a label.
49
+ 3. Declared license against the SPDX/common-identifier fallback table.
50
+ 4. Unknown.
51
+ """
52
+ normalized = model_id.strip().lower()
53
+
54
+ if declared_license:
55
+ lic = declared_license.strip().lower()
56
+ if any(marker in lic for marker in NON_COMMERCIAL_MARKERS):
57
+ return LicenseVerdict(
58
+ model_id=model_id,
59
+ license_id=declared_license,
60
+ tier=Tier.STRONG_RESTRICTION,
61
+ obligation="Non-commercial-only license (CC-BY-NC family); "
62
+ "commercial deployment is not permitted under these terms.",
63
+ )
64
+
65
+ for prefix, license_id, tier, obligation in KNOWN_FAMILIES:
66
+ if prefix in normalized:
67
+ return LicenseVerdict(
68
+ model_id=model_id,
69
+ license_id=license_id,
70
+ tier=tier,
71
+ obligation=obligation,
72
+ )
73
+
74
+ if declared_license:
75
+ lic = declared_license.strip().lower()
76
+ if lic in DECLARED_LICENSE_TIERS:
77
+ tier, obligation = DECLARED_LICENSE_TIERS[lic]
78
+ return LicenseVerdict(
79
+ model_id=model_id,
80
+ license_id=declared_license,
81
+ tier=tier,
82
+ obligation=obligation,
83
+ )
84
+
85
+ return LicenseVerdict(
86
+ model_id=model_id,
87
+ license_id=declared_license or "unknown",
88
+ tier=Tier.UNKNOWN,
89
+ obligation="No license metadata found. Treat as highest risk until "
90
+ "manually verified -- roughly 70% of Hugging Face models ship "
91
+ "with no license at all.",
92
+ )