Abu-Sameer-66 commited on
Commit
535dd76
Β·
0 Parent(s):

fix: v2.3.4 final version bump

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. .gitignore +0 -0
  3. Dockerfile +8 -0
  4. LICENSE +21 -0
  5. Procfile +1 -0
  6. README.md +11 -0
  7. data/scipeerai_bench/schema.py +154 -0
  8. data/scipeerai_bench/scipeerai_bench_borderline.csv +129 -0
  9. data/scipeerai_bench/scipeerai_bench_clean.csv +0 -0
  10. data/scipeerai_bench/scipeerai_bench_fraud.csv +0 -0
  11. data/scipeerai_bench/scipeerai_bench_sample.csv +13 -0
  12. data/scipeerai_bench/scipeerai_bench_v1.1.csv +0 -0
  13. data/scipeerai_bench/scipeerai_bench_v1.csv +0 -0
  14. main.py +7 -0
  15. models/model_config_honest.json +37 -0
  16. models/replication_predictor_honest.pkl +3 -0
  17. models/v2/discovery1_linguistic_divergence.csv +11 -0
  18. models/v2/discovery3_temporal_drift.csv +6 -0
  19. models/v2/discovery4_shap.csv +38 -0
  20. models/v2/discovery4_shap_direction.csv +38 -0
  21. models/v2/ensemble_config_v2.json +58 -0
  22. models/v2/feature_importance_v2.csv +38 -0
  23. models/v2/lgbm_v2.pkl +3 -0
  24. models/v2/lr_v2.pkl +3 -0
  25. models/v2/xgb_v2.pkl +3 -0
  26. railway.json +11 -0
  27. requirements.txt +19 -0
  28. src/scipeerai/__init__.py +0 -0
  29. src/scipeerai/api/__init__.py +35 -0
  30. src/scipeerai/api/routes.py +0 -0
  31. src/scipeerai/core/__init__.py +0 -0
  32. src/scipeerai/core/pdf_parser.py +215 -0
  33. src/scipeerai/modules/__init__.py +0 -0
  34. src/scipeerai/modules/ai_human_spectrum.py +556 -0
  35. src/scipeerai/modules/citation_analyzer.py +413 -0
  36. src/scipeerai/modules/citation_cartel.py +344 -0
  37. src/scipeerai/modules/citation_dna.py +516 -0
  38. src/scipeerai/modules/data_fingerprint.py +463 -0
  39. src/scipeerai/modules/effect_size_validator.py +329 -0
  40. src/scipeerai/modules/field_saturation.py +407 -0
  41. src/scipeerai/modules/figure_forensics.py +338 -0
  42. src/scipeerai/modules/fraud_fingerprint.py +524 -0
  43. src/scipeerai/modules/granularity_analyzer.py +369 -0
  44. src/scipeerai/modules/grim_test.py +167 -0
  45. src/scipeerai/modules/institutional_conflict.py +412 -0
  46. src/scipeerai/modules/llm_detector.py +335 -0
  47. src/scipeerai/modules/methodology_checker.py +549 -0
  48. src/scipeerai/modules/novelty_scorer.py +390 -0
  49. src/scipeerai/modules/pcurve_analyzer.py +228 -0
  50. src/scipeerai/modules/peer_review_score.py +522 -0
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.pkl filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
Binary file (316 Bytes). View file
 
Dockerfile ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ ο»ΏFROM python:3.10-slim
2
+ WORKDIR /app
3
+ RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 && rm -rf /var/lib/apt/lists/*
4
+ COPY requirements.txt .
5
+ RUN pip install --no-cache-dir -r requirements.txt
6
+ COPY . .
7
+ EXPOSE 7860
8
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Abu-Sameer
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.
Procfile ADDED
@@ -0,0 +1 @@
 
 
1
+ web: uvicorn main:app --host 0.0.0.0 --port $PORT
README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ο»Ώ---
2
+ title: SciPeerAI API
3
+ emoji: πŸ”¬
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ pinned: false
8
+ ---
9
+
10
+ # SciPeerAI API
11
+ Automated Scientific Integrity Analysis β€” 14-Module Detection Pipeline
data/scipeerai_bench/schema.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SciPeerBench β€” Dataset schema definition.
3
+
4
+ World's first multi-dimensional scientific fraud benchmark.
5
+ Every paper labeled across 14 fraud dimensions simultaneously.
6
+ No other dataset does this.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Optional
11
+
12
+
13
+ # ── Fraud taxonomy β€” 20 types, first time formally defined ───────────────────
14
+
15
+ FRAUD_TAXONOMY = {
16
+ # Data fabrication
17
+ "FAB-01": "Complete data fabrication",
18
+ "FAB-02": "Partial data fabrication",
19
+ "FAB-03": "Data duplication across papers",
20
+
21
+ # Statistical manipulation
22
+ "STAT-01": "P-hacking and selective reporting",
23
+ "STAT-02": "HARKing β€” hypothesizing after results known",
24
+ "STAT-03": "Impossible statistical values GRIM or SPRITE",
25
+ "STAT-04": "Inflated effect sizes",
26
+ "STAT-05": "Underpowered study with strong claims",
27
+
28
+ # Figure fraud
29
+ "FIG-01": "Duplicated figure panels",
30
+ "FIG-02": "Manipulated western blots",
31
+ "FIG-03": "Image brightness or contrast manipulation",
32
+
33
+ # Citation fraud
34
+ "CIT-01": "Excessive self-citation ring",
35
+ "CIT-02": "Citation cartel coordinated group",
36
+ "CIT-03": "Unsupported claims without citation",
37
+
38
+ # Methodology fraud
39
+ "METH-01": "Causation claimed without RCT",
40
+ "METH-02": "Missing control group",
41
+ "METH-03": "Undisclosed conflicts of interest",
42
+
43
+ # Authorship and integrity
44
+ "AUTH-01": "LLM-generated paper",
45
+ "AUTH-02": "Plagiarism detected",
46
+ "AUTH-03": "Retracted paper cited as valid",
47
+ }
48
+
49
+
50
+ # ── Paper categories for balanced dataset ────────────────────────────────────
51
+
52
+ PAPER_CATEGORIES = {
53
+ "CONFIRMED_FRAUD": "Retracted with documented fraud reason",
54
+ "SUSPECTED_FRAUD": "PubPeer flagged, not retracted yet",
55
+ "BORDERLINE": "Minor issues, not clear fraud",
56
+ "CLEAN": "High quality, replicated, no concerns",
57
+ "BASELINE_ELITE": "Nobel prize or landmark papers",
58
+ }
59
+
60
+
61
+ # ── Source databases ──────────────────────────────────────────────────────────
62
+
63
+ DATA_SOURCES = {
64
+ "retraction_watch": "https://retractionwatch.com",
65
+ "pubpeer": "https://pubpeer.com",
66
+ "pubmed": "https://pubmed.ncbi.nlm.nih.gov",
67
+ "arxiv": "https://arxiv.org",
68
+ "semantic_scholar": "https://api.semanticscholar.org",
69
+ "crossref": "https://api.crossref.org",
70
+ }
71
+
72
+
73
+ # ── Target distribution β€” 1000 papers total ──────────────────────────────────
74
+
75
+ TARGET_DISTRIBUTION = {
76
+ "CONFIRMED_FRAUD": 300, # from RetractionWatch
77
+ "SUSPECTED_FRAUD": 200, # from PubPeer
78
+ "BORDERLINE": 150, # gray area
79
+ "CLEAN": 250, # normal good papers
80
+ "BASELINE_ELITE": 100, # Nobel / landmark
81
+ }
82
+
83
+
84
+ @dataclass
85
+ class PaperRecord:
86
+ """
87
+ One row in SciPeerBench.
88
+ Every paper labeled across all 14 fraud dimensions.
89
+ This is the most comprehensive fraud labeling schema ever built.
90
+ """
91
+
92
+ # ── Identity ──────────────────────────────────────────────────
93
+ paper_id: str # SPB-0001, SPB-0002 ...
94
+ doi: Optional[str]
95
+ title: str
96
+ authors: str # comma separated
97
+ year: int
98
+ journal: str
99
+ source_db: str # where we got it
100
+
101
+ # ── Ground truth ──────────────────────────────────────────────
102
+ category: str # from PAPER_CATEGORIES
103
+ is_fraud: int # 1 = fraud, 0 = clean
104
+ fraud_confidence: float # 0.0 to 1.0
105
+ fraud_types: str # comma separated FRAUD_TAXONOMY keys
106
+ retraction_date: Optional[str] # YYYY-MM-DD
107
+ retraction_reason: Optional[str]
108
+ pubpeer_url: Optional[str]
109
+
110
+ # ── 14 module scores β€” auto-generated by running our system ───
111
+ stat_audit_score: float = 0.0
112
+ figure_forensics_score: float = 0.0
113
+ methodology_score: float = 0.0
114
+ citation_score: float = 0.0
115
+ reproducibility_score: float = 0.0
116
+ novelty_score: float = 0.0
117
+ grim_score: float = 0.0
118
+ sprite_score: float = 0.0
119
+ granularity_score: float = 0.0
120
+ pcurve_score: float = 0.0
121
+ effect_size_score: float = 0.0
122
+ retraction_score: float = 0.0
123
+ cartel_score: float = 0.0
124
+ llm_score: float = 0.0
125
+
126
+ # ── Weighted average of all 14 scores ─────────────────────────
127
+ overall_risk_score: float = 0.0
128
+
129
+ # ── Paper content ─────────────────────────────────────────────
130
+ abstract_text: str = ""
131
+ full_text_path: str = "" # path to saved full text
132
+
133
+ # ── Metadata ──────────────────────────────────────────────────
134
+ field_of_study: str = "" # biology, psychology, medicine...
135
+ labeling_method: str = "" # auto, manual, auto+manual
136
+ labeled_by: str = "auto"
137
+ notes: str = ""
138
+
139
+
140
+ # ── CSV column order β€” exact order in output file ────────────────────────────
141
+
142
+ CSV_COLUMNS = [
143
+ "paper_id", "doi", "title", "authors", "year",
144
+ "journal", "source_db",
145
+ "category", "is_fraud", "fraud_confidence",
146
+ "fraud_types", "retraction_date", "retraction_reason", "pubpeer_url",
147
+ "stat_audit_score", "figure_forensics_score", "methodology_score",
148
+ "citation_score", "reproducibility_score", "novelty_score",
149
+ "grim_score", "sprite_score", "granularity_score", "pcurve_score",
150
+ "effect_size_score", "retraction_score", "cartel_score", "llm_score",
151
+ "overall_risk_score",
152
+ "abstract_text", "full_text_path",
153
+ "field_of_study", "labeling_method", "labeled_by", "notes",
154
+ ]
data/scipeerai_bench/scipeerai_bench_borderline.csv ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ paper_id,doi,title,authors,year,journal,source_db,category,is_fraud,fraud_confidence,fraud_types,retraction_date,retraction_reason,pubpeer_url,stat_audit_score,figure_forensics_score,methodology_score,citation_score,reproducibility_score,novelty_score,grim_score,sprite_score,granularity_score,pcurve_score,effect_size_score,retraction_score,cartel_score,llm_score,overall_risk_score,abstract_text,full_text_path,field_of_study,labeling_method,labeled_by,notes
2
+ SPB-0525,10.1159/000548698,Expression of Concern.,Unknown,2025,Neuroimmunomodulation,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41363674 β€” expression of concern
3
+ SPB-0526,10.1159/000549719,Expression of Concern.,Unknown,2026,Chemotherapy,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41563927 β€” expression of concern
4
+ SPB-0527,10.1159/000548461,Expression of Concern.,Unknown,2026,International archives of allergy and immunology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41231741 β€” expression of concern
5
+ SPB-0528,10.1080/19336950.2025.2570092,Expression of Concern.,Unknown,2025,"Channels (Austin, Tex.)",pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41065763 β€” expression of concern
6
+ SPB-0529,10.1080/14712598.2025.2596474,Expression of Concern.,Unknown,2025,Expert opinion on biological therapy,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41404834 β€” expression of concern
7
+ SPB-0530,10.1016/j.biopsych.2025.09.009,Expression of Concern.,Unknown,2025,Biological psychiatry,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41238290 β€” expression of concern
8
+ SPB-0531,10.1177/09287329251392360,Expression of concern.,Unknown,2025,Technology and health care : official journal of the European Society for Engineering and Medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41223024 β€” expression of concern
9
+ SPB-0532,10.1177/10445498261435664,Expression of Concern.,Unknown,2026,DNA and cell biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41882964 β€” expression of concern
10
+ SPB-0533,10.1002/ijgo.70756,Expression of Concern.,Unknown,2026,International journal of gynaecology and obstetrics: the official organ of the International Federation of Gynaecology and Obstetrics,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41437141 β€” expression of concern
11
+ SPB-0534,10.1302/2046-3758.151.BJR-2026-00002,Expression of Concern.,"Zhang Hongbo, Li Ziyang, Zhang Zhicheng, Li Haobin, Yao Zihao, et al.",2026,Bone & joint research,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41575261 β€” expression of concern
12
+ SPB-0535,10.26355/eurrev_202512_37562,"Expression of Concern on ""Effects of inositol on ovarian function and metabolic factors in women with PCOS: a randomized double blind placebo-controlled trial"".",Unknown,2025,European review for medical and pharmacological sciences,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"The Editor in Chief and the Publisher are issuing an expression of concern regarding the following article: Gerli S, Mignosa M, Di Renzo GC. Effects of inositol on ovarian function and metabolic factors in women with PCOS: a randomized double blind placebo-controlled trial. Eur Rev Med Pharmacol Sci 2003; 7(6): 151-159-PMID: 15206484. This Expression of Concern is issued following a whistleblower's report regarding errors in the study protocol and results, as well as ethical concerns. The authors have informed the journal that the original data are no longer available due to the 22 years that have elapsed since the article's publication.Β In the absence of these data, we are issuing this Expression of Concern to alert readers that concerns have been raised about this article. The Publisher apologizes for any inconvenience this may cause. https://www.europeanreview.org/article/94.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41489236 β€” expression of concern
13
+ SPB-0536,10.1172/JCI200302,Expression of Concern for JAK2-binding long noncoding RNA promotes breast cancer brain metastasis.,"Wang Shouyu, Liang Ke, Hu Qingsong, Li Ping, Song Jian, et al.",2025,The Journal of clinical investigation,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41031879 β€” expression of concern
14
+ SPB-0537,10.1126/sciimmunol.aed4813,Editorial expression of concern.,Scanlon Seth Thomas,2025,Science immunology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41270191 β€” expression of concern
15
+ SPB-0538,10.1126/sciadv.aec3110,Editorial expression of concern.,Thorp H Holden,2025,Science advances,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41032620 β€” expression of concern
16
+ SPB-0539,10.1038/s41586-025-09942-8,Editorial Expression of Concern: Cytokinesis failure generating tetraploids promotes tumorigenesis in p53-null cells.,"Fujiwara Takeshi, Bandi Madhavi, Nitta Masayuki, Ivanova Elena V, Bronson Roderick T, et al.",2025,Nature,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41326737 β€” expression of concern
17
+ SPB-0540,10.1111/exd.70170,EXPRESSION OF CONCERN: Dermal Papilla Cell-Secreted Biglycan Regulates Hair Follicle Phase Transit and Regeneration by Activating Wnt/Ξ²-Catenin.,Unknown,2025,Experimental dermatology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"N. Zhu, J. Yan, W. Gu, Q. Yang, E. Lin, S. Lu, B. Cai, B. Xia, X. Liu, C. Lin, ""Dermal Papilla Cell-Secreted Biglycan Regulates Hair Follicle Phase Transit and Regeneration by Activating Wnt/Ξ²-Catenin,"" Experimental Dermatology 33, no. 1 (2024): e14969, https://doi.org/10.1111/exd.14969. This Expression of Concern is for the above article, published online on 15 November 2023 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Akimichi Morita, and John Wiley & Sons Ltd. The Expression of Concern has been agreed upon following the identification of duplicated GAPDH bands in FiguresΒ 1C and 2C. Additional concerns were raised about the authenticity of bands in FiguresΒ 2C and 3C, which lacked characteristics typically associated with original experimental data. Although the authors cooperated and submitted data, the materials provided did not meet the standards for raw data and were insufficient to validate the figures. Th",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41131858 β€” expression of concern
18
+ SPB-0541,10.1097/MD.0000000000046330,Expression of Concern: Study Protocols.,Unknown,2025,Medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41204616 β€” expression of concern
19
+ SPB-0542,10.1038/s41591-025-04116-5,Editorial Expression of Concern: Rationale for co-targeting IGF-1R and ALK in ALK fusion-positive lung cancer.,"Lovly Christine M, McDonald Nerina T, Chen Heidi, Ortiz-Cuaran Sandra, Heukamp Lukas C, et al.",2025,Nature medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41266687 β€” expression of concern
20
+ SPB-0543,10.1038/s41594-025-01730-2,Editorial Expression of Concern: Munc13 C<sub>2</sub>B domain is an activity-dependent Ca<sup>2+</sup> regulator of synaptic exocytosis.,"Shin Ok-Ho, Lu Jun, Rhee Jeong-Seop, Tomchick Diana R, Pang Zhiping P, et al.",2025,Nature structural & molecular biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41339862 β€” expression of concern
21
+ SPB-0544,10.1177/08977151261430329,Expression of Concern.,Unknown,2026,Journal of neurotrauma,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41882968 β€” expression of concern
22
+ SPB-0545,10.1111/eci.70186,EXPRESSION OF CONCERN: COVID-19 re-infection.,Unknown,2026,European journal of clinical investigation,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41738956 β€” expression of concern
23
+ SPB-0546,10.1016/j.heliyon.2025.e44226,"Expression of concern: ""SQSTM1/p62 promotes the progression of gastric cancer through epithelial-mesenchymal transition"" [Heliyon 10 (2024) e24409].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41497880 β€” expression of concern
24
+ SPB-0547,10.1172/JCI202595,Expression of Concern for Axon guidance cue SLIT2 regulates the murine skeletal stem cell niche through sympathetic innervation.,"Wu Zuoxing, Li Na, Luo Zhengqiong, Chen Zihan, He Xuemei, et al.",2025,The Journal of clinical investigation,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41392979 β€” expression of concern
25
+ SPB-0548,10.1016/j.biopha.2025.118302,Expression of Concern - Organ provenance case.,Unknown,2025,Biomedicine & pharmacotherapy = Biomedecine & pharmacotherapie,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41233974 β€” expression of concern
26
+ SPB-0549,10.1038/s41564-025-02195-1,Editorial Expression of Concern: Metabolic remodelling produces fumarate via the aspartate-argininosuccinate shunt in macrophages as an antiviral defence.,"Xia Wenjun, Mao Youxiang, Xia Ziyan, Cheng Jie, Jiang Peng",2025,Nature microbiology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41188385 β€” expression of concern
27
+ SPB-0550,10.1038/s41586-026-10247-7,Editorial Expression of Concern: Transcription-independent ARF regulation in oncogenic stress-mediated p53 responses.,"Chen Delin, Shan Jing, Zhu Wei-Guo, Qin Jun, Gu Wei",2026,Nature,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41699183 β€” expression of concern
28
+ SPB-0551,10.1186/s12933-025-03045-4,Editorial Expression of Concern: High triglyceride‑glucose index and stress hyperglycemia ratio as predictors of adverse cardiac events in patients with coronary chronic total occlusion: a large‑scale prospective cohort study.,"Song Yanjun, Cui Kongyong, Yang Min, Song Chenxi, Yin Dong, et al.",2025,Cardiovascular diabetology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41437363 β€” expression of concern
29
+ SPB-0552,10.1038/s41591-025-04149-w,Editorial Expression of Concern: Amplification of LAPTM4B and YWHAZ contributes to chemotherapy resistance and recurrence of breast cancer.,"Li Yang, Zou Lihua, Li Qiyuan, Haibe-Kains Benjamin, Tian Ruiyang, et al.",2025,Nature medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41350453 β€” expression of concern
30
+ SPB-0553,10.3892/ijo.2025.5829,[Expression of Concern] MUC1 is a downstream target of STAT3 and regulates lung cancer cell survival and invasion.,"Gao Jingchun, Mcconnell Matthew J, Yu Bin, Li Jiannong, Balko Justin M, et al.",2026,International journal of oncology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, it was drawn to the Editor's attention by a concerned reader that the Bcl‑2 protein bands shown for the HCC827 cell line in the western blots in Fig. 3A on p. 342 were unexpectedly similar to the Akt protein bands shown for the A549 cell line in Fig. 3B. In addition, the Bcl‑2 protein bands shown for the H358 and HCC827 cell lines (the left‑hand and middle gels) possibly contained a pair of mutually overlapping protein bands, and the β‑actin control bands featured in the left‑hand and middle lanes of Fig. 3A for the H358 and HCC827 cell lines, and the β‑actin control bands featured in the middle and right‑hand lanes for the H358 and HCC827 cell lines in Fig. 1C on p. 340, were similarly more similar than might have been expected. The authors were contacted by the Editorial Office to offer an explanation for this possible anomaly in the presentation of the data in this paper, although up to this time, no response from them has been forthcomi",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41347839 β€” expression of concern
31
+ SPB-0554,10.1038/s41591-026-04295-9,Editorial Expression of Concern: Rescue of Hippo coactivator YAP1 triggers DNA damage-induced apoptosis in hematological cancers.,"Cottini Francesca, Hideshima Teru, Xu Chunxiao, Sattler Martin, Dori Martina, et al.",2026,Nature medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41741727 β€” expression of concern
32
+ SPB-0555,10.1136/bmj.r2395,EXPRESSION OF CONCERN: Participation in early mammography screening.,Unknown,2025,BMJ (Clinical research ed.),pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41290351 β€” expression of concern
33
+ SPB-0556,10.1038/s41591-026-04242-8,Editorial Expression of Concern: Direct targeting of Sec23a by miR-200s influences cancer cell secretome and promotes metastatic colonization.,"Korpal Manav, Ell Brian J, Buffa Francesca M, Ibrahim Toni, Blanco Mario A, et al.",2026,Nature medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41639381 β€” expression of concern
34
+ SPB-0557,10.1002/trc2.70224,EXPRESSION OF CONCERN: Focused Transcranial Ultrasound for Treatment of Neurodegenerative Dementia.,Unknown,2026,"Alzheimer's & dementia (New York, N. Y.)",pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"N. E. Nicodemus, S. Becerra, T. P. Kuhn, H. R. Packham, J. Duncan, K. Mahdavi, J. Iovine, S. Kesari, S. Pereles, M. Whitney, M. Mamoun, D. Franc, A. Bystritsky, and S. Jordan, ""Focused Transcranial Ultrasound for Treatment of Neurodegenerative Dementia,"" Alzheimer's & Dementia: Translational Research & Clinical InterventionsΒ 5, no. 1 (2019): 374-381, https://doi.org/10.1016/j.trci.2019.06.007. This Expression of Concern is for the above article, published online on 9 August 2019 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Barry D. Greenberg; theΒ Alzheimer's Association; and Wiley Periodicals LLC. The Expression of Concern has been agreed due to concerns raised by third parties. Specifically, contradictory and/or unsupported statements have been identified in the article. In addition, there are concerns regarding potentially undisclosed conflicts of interest for one or more of the co-authors, related to possible",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41768474 β€” expression of concern
35
+ SPB-0558,10.1097/AOG.0000000000006180,Expression of Concern: Fezolinetant and Elinzanetant Therapy for Menopausal Women Experiencing Vasomotor Symptoms: A Systematic Review and Meta-Analysis.,Unknown,2026,Obstetrics and gynecology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41538797 β€” expression of concern
36
+ SPB-0559,10.2807/1560-7917.ES.2020.31.3.260122ec,Expression of concern for Euro Surveill. 2026;31(2).,Unknown,2026,Euro surveillance : bulletin Europeen sur les maladies transmissibles = European communicable disease bulletin,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41546487 β€” expression of concern
37
+ SPB-0560,10.1161/JAHA.125.045055,Expression of Concern: Epothilone B Benefits Nigrostriatal Pathway Recovery by Promoting Microtubule Stabilization After Intracerebral Hemorrhage.,Unknown,2025,Journal of the American Heart Association,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"The Editors of Journal of the American Heart Association (JAHA) are issuing an Expression of Concern regarding tge article, ""Epothilone B Benefits Nigrostriatal Pathway Recovery by Promoting Microtubule Stabilization After Intracerebral Hemorrhage"" (JAHA. 2018. DOI: https://doi.org/10.1161/JAHA.117.007626). Concerns have been raised regarding the quality of data and images in the aforementioned article. The editors and Scientific Publishing Committee are in communication with the authors regarding the integrity of the image data, and Loma Linda University has confirmed the concerns are being reviewed. While we await the outcome of this review, we are publishing this Expression of Concern to indicate that the data and statements in the article may not be reliable.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41085180 β€” expression of concern
38
+ SPB-0561,10.3892/ijo.2025.5808,[Expression of Concern] miR‑221/222 promote malignant progression of glioma through activation of the Akt pathway.,"Zhang Junxia, Han Lei, Ge Youlin, Zhou Xuan, Zhang Anling, et al.",2025,International journal of oncology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, a concerned reader drew to the Editor's attention that, for the immuno-histochemistry images shown in Fig. 6, the Control/PCNA and Control/p27kip1 panels appeared to be duplicates of each other, where the results of differently performed experiments were intended to have been portrayed. The authors were contacted by the Editorial Office to offer an explanation for this potential anomaly in the presentation of the data in this paper, although up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [International Journal of Oncology 36: 913‑920, 2010; DOI: 10.3892/ijo_00000570].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41070621 β€” expression of concern
39
+ SPB-0562,10.3892/or.2025.8989,[Expression of Concern] Hypoxia and macrophages promote glioblastoma invasion by the CCL4‑CCR5 axis.,"Wang Ying, Liu Tao, Yang Ning, Xu Shuo, Li Xingang, et al.",2025,Oncology reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of this paper, it was drawn to the Editor's attention by a concerned reader that the 'Normoxia / U87' panel in Fig. 1A appeared to overlap with the 'CCR5 siRNA' panel in Fig. 2B; in addition, the 'Hypoxia / U87‑MΟ†' panel in Fig. 1A appeared to overlap with the 'Control siRNA' panel in Fig. 2B. The authors were contacted by the Editorial Office to offer an explanation for these apparent anomalies in the presentation of the data in this paper; however, up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [Oncology Reports 36: 3522‑3528, 2016; DOI: 10.3892/or.2016.5171].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:40970355 β€” expression of concern
40
+ SPB-0563,10.1111/1755-0998.70061,EXPRESSION OF CONCERN: A Novel Technique for Estimating Age and Demography of Long-Lived Seabirds (Genus Pterodroma) Using an Epigenetic Clock for Gould's Petrel (Pterodroma leucoptera).,Unknown,2025,Molecular ecology resources,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"L. Roman, B. Mayne, C. Anderson, Y. Kim, T. O'Dwyer, and N. Carlile, ""A Novel Technique for Estimating Age and Demography of Long-Lived Seabirds (Genus Pterodroma) Using an Epigenetic Clock for Gould's Petrel (Pterodroma leucoptera),"" Molecular Ecology Resources 24, no. 7 (2024): e14003, https://doi.org/10.1111/1755-0998.14003. This Expression of Concern is for the above article, published online on 29 July 2024 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Ben Sibbett; and John Wiley & Sons Ltd. The Expression of Concern has been agreed upon following concerns raised by one of the authors, L. Roman, regarding potential inaccuracies in the data presented in the article. These concerns suggest the study may not be reproducible. The institution is currently investigating the matter and has requested that the Expression of Concern be published during the course of the investigation.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41099181 β€” expression of concern
41
+ SPB-0564,10.1093/lambio/ovaf139,Expression of concern: Enhancement of microbial growth in Pseudomonas species through mutagenesis.,Unknown,2025,Letters in applied microbiology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41399940 β€” expression of concern
42
+ SPB-0565,10.1038/s41590-025-02379-1,"Editorial Expression of Concern: HoxC4 binds to the promoter of the cytidine deaminase AID gene to induce AID expression, class-switch DNA recombination and somatic hypermutation.","Park Seok-Rae, Zan Hong, Pal Zsuzsanna, Zhang Jinsong, Al-Qahtani Ahmed, et al.",2026,Nature immunology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41388154 β€” expression of concern
43
+ SPB-0566,10.3892/mmr.2025.13688,[Expression of Concern] PirB restricts neuronal regeneration in developing rat brain following hypoxia‑ischemia.,"Wang Hua, Xiong Ying, Mu Dezhi",2025,Molecular medicine reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of this paper, it was drawn to the Editor's attention by a concerned reader that, regarding the immunofluorescence images shown in Fig. 3, the 'HIBD' panel appeared to share an overlapping section of data with the adjoining 'HIBD+anti PirB' panel, albeit the data held in common appeared to have been vertically stretched in the latter panel. The authors were contacted by the Editorial Office to offer an explanation for this apparent anomaly in the presentation of the data in this paper; however, up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [Molecular Medicine Reports 6: 339‑344, 2012; DOI: 10.3892/mmr.2012.907].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:40970342 β€” expression of concern
44
+ SPB-0567,10.1161/JAHA.125.044453,Expression of Concern: Netrin-1 Preserves Blood-Brain Barrier Integrity Through Deleted in Colorectal Cancer/Focal Adhesion Kinase/RhoA Signaling Pathway Following Subarachnoid Hemorrhage inΒ Rats.,Unknown,2025,Journal of the American Heart Association,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"The Editors of the Journal of the American Heart Association (JAHA) are issuing an Expression of Concern regarding the article, ""Netrin-1 Preserves Blood-Brain Barrier Integrity Through Deleted in Colorectal Cancer/Focal Adhesion Kinase/RhoA Signaling Pathway Following Subarachnoid Hemorrhage in Rats"" (JAHA. 2017. DOI: https://doi.org/10.1161/JAHA.116.005198). Concerns have been raised regarding the quality of data and images in the aforementioned article. The editors and Scientific Publishing Committee are in communication with the authors regarding the integrity of the image data, and Loma Linda University has confirmed the concerns are being reviewed. While we await the outcome of this review, we are publishing this Expression of Concern to indicate that the data and statements in the article may not be reliable.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41085211 β€” expression of concern
45
+ SPB-0568,10.3892/mmr.2026.13843,[Expression of Concern] Dexmedetomidine protects against sepsis‑associated encephalopathy through Hsp90/AKT signaling.,"Yin Lijun, Chen Xuejun, Ji Hongbo, Gao Shunli",2026,Molecular medicine reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of this paper, it was drawn to the Editor's attention by a concerned reader that western blot data featured in Fig. 4A were strikingly similar to data that appeared subsequently in the journal Journal of Clinical Science that was written by different authors. Additionally, the right‑hand β‑actin band in Fig. 1A was apparently the same as the left‑hand β‑actin band in Fig. 1B; the right‑hand band in the gel for cleaved caspase‑3 in Fig. 5A was apparently the same as the left‑hand protein band for the Bcl‑2 blots in Fig. 5B; and the β‑actin bands in Fig. 4A seemed to match with most of the β‑actin bands in Fig. 1A.Β The authors have been contacted by the Editorial Office to offer an explanation for these apparent anomalies in the presentation of the data in this paper; however, up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of thi",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41823530 β€” expression of concern
46
+ SPB-0569,10.1002/1873-3468.70221,EXPRESSION OF CONCERN: Mitogen-Activated Protein Kinase Phosphatase-1 (MKP-1): >100-Fold Nocturnal and Norepinephrine-Induced Changes in the Rat Pineal Gland.,Unknown,2025,FEBS letters,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Expression of Concern: D.M. Price, C.L. Chik, D. Terriff, J. Weller, A. Humphries, D.A. Carter, D.C. Klein, and A.K. Ho, ""Mitogen-Activated Protein Kinase Phosphatase-1 (MKP-1): >100-Fold Nocturnal and Norepinephrine-Induced Changes in the Rat Pineal Gland,"" FEBS Letters 577, no. 1-2 (2004): 220-226, https://doi.org/10.1016/j.febslet.2004.09.083. This Expression of Concern is for the above article, published online on 18 October 2004 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Michael Brunner; Federation of European Biochemical Societies (FEBS); and John Wiley & Sons Ltd. A third party alerted the journal to possible duplications of the GADPH loading control bands in Figures 1B, 2B, and 3B. The journal verified the duplications and the publisher attempted to contact the authors for original data and comments. The authors did not respond to the publisher's requests. In the absence of the original data, the journ",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41223011 β€” expression of concern
47
+ SPB-0570,10.1177/03000605251408199,"Expression of concern: ""Perioperative risk factors for pulmonary complications after liver transplantation"".",Unknown,2025,The Journal of international medical research,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41456180 β€” expression of concern
48
+ SPB-0571,10.1111/acer.70270,"EXPRESSION OF CONCERN: Tattoos, Piercings, and Alcohol Consumption.",Unknown,2026,"Alcohol, clinical & experimental research",pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41879256 β€” expression of concern
49
+ SPB-0572,10.1002/jcb.70070,"EXPRESSION OF CONCERN: Kaempferol Induces Apoptosis in Two Different Cell Lines via Akt Inactivation, Bax and SIRT3 Activation, and Mitochondrial Dysfunction†.",Unknown,2025,Journal of cellular biochemistry,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"G. Marfe, M. Tafani, M. Indelicato, P. Sinibaldi-Salimei, V. Reali, B. Pucci, M. Fini and M. A. Russo ""Kaempferol Induces Apoptosis in Two Different Cell Lines via Akt Inactivation, Bax and SIRT3 Activation, and Mitochondrial Dysfunction†,"" Journal of Cellular Biochemistry 106, no. 4 (2009): 643-650, https://doi.org/10.1002/jcb.22044. This Expression of Concern is for the above article, published online on 21 January 2009 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Christian Behl; and Wiley Periodicals LLC. The Expression of Concern has been issued due to the identification of duplicated Ξ²-actin bands for the K562 and U937 cell lines in Figure 6 A. The authors agree that the bands appear very similar and believe this resulted from figure mismanagement. Due to the time that has elapsed since the study was conducted, the authors were unable to produce the original blots. The editors are satisfied that the article",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41217004 β€” expression of concern
50
+ SPB-0573,10.1016/j.molpha.2026.100106,"Expressions of Concern, Corrigenda, and a Retraction.","Greenwood-Van Meerveld Beverley, Tesmer John, Jarvis Michael",2027,Molecular pharmacology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41935897 β€” expression of concern
51
+ SPB-0574,10.1016/j.jpet.2026.103834,"Expressions of Concern, Corrigenda, and a Retraction.","Greenwood-Van Meerveld Beverley, Tesmer John J G, Jarvis Michael",2027,The Journal of pharmacology and experimental therapeutics,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41935933 β€” expression of concern
52
+ SPB-0575,10.1371/journal.pone.0338753,Expression of Concern: Governance and competitiveness evaluation of China's financial asset management corporations.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41396866 β€” expression of concern
53
+ SPB-0576,10.14814/phy2.70689,EXPRESSION OF CONCERN: Cardiomyopathy Characterizing and Heart Failure Risk Predicting by Echocardiography and Pathoanatomy in Aged Male Mice.,Unknown,2025,Physiological reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"X.-J. Du, X.-H. Feng, Z.-Q. Ming, and H. Kiriazis, ""Cardiomyopathy Characterizing and Heart Failure Risk Predicting by Echocardiography and Pathoanatomy in Aged Male Mice,"" Physiological Reports 12, no. 20 (2024): e70061. https://doi.org/10.14814/phy2.70061. This Expression of Concern is for the above article, published online on October 16, 2024 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Josephine C. Adams; The American Physiological Society; The Physiological Society; and Wiley Periodicals LLC. The Expression of Concern has been agreed upon after it was determined that the TG panel in figureΒ 3a is duplicated from an article published in another journal 14 years earlier by an author common to both publications. The authors cooperated with the investigation and explained that the duplication was an inadvertent error; they also provided supporting data. However, although the conclusions are believed to remain u",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41335528 β€” expression of concern
54
+ SPB-0577,10.3892/ijo.2025.5814,[Expression of Concern] Regulation of NADPH oxidase (Nox2) by lipid rafts in breast carcinoma cells.,"Malla Rama Rao, Raghu Hari, Rao Jasti S",2026,International journal of oncology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, a potential problem regarding the presentation of the co‑localization experiments shown in Fig. 5A and C was brought to the Editor's attention by a concerned reader. Specifically, the Flotillin/gp91 co‑localization panels (Fig. 5A) appeared to be unexpectedly similar to the Flotillin/p22 panels (Fig. 5C), even though, according to the Materials and methods section, the different antibody treatments that were reported might have precluded the possibility of these images looking so similar. The authors were contacted by the Editorial Office to offer an explanation for this potential anomaly in the presentation of the data in this paper (or to clarify how the experiments had been performed), although up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify ",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41133462 β€” expression of concern
55
+ SPB-0578,10.1111/jpi.70126,EXPRESSION OF CONCERN: Melatonin Protects Against Endometriosis via Regulation of Matrix Metalloproteinase-3 and an Apoptotic Pathway.,Unknown,2026,Journal of pineal research,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"S. Paul, P. Bhattacharya, P. D. Mahapatra, and S. Swarnakar, ""Melatonin Protects Against Endometriosis via Regulation of Matrix Metalloproteinase-3 and an Apoptotic Pathway,"" Journal of Pineal Research 49, no. 2 (2010): 156-168, https://doi.org/10.1111/j.1600-079X.2010.00780.x. This Expression of Concern is for the above article, published online on 02 August 2010 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Gianluca Tosini; and John Wiley & Sons Ltd. The Expression of Concern has been agreed due to concerns raised by third parties. Specifically, the features of the sections presented in Figureβ€―6 (A), (B), and (C) were found to closely resemble those of small intestine rather than endometrial (uterine) tissue in mice. Due to the time elapsed since publication, the authors were unable to retrieve the raw data underlying the images. Despite the authors highlighting several features in their clarification to suppor",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41769762 β€” expression of concern
56
+ SPB-0579,10.1016/j.heliyon.2025.e44222,"Expression of concern: ""On stratified single-valued soft topogenous structures"" [Heliyon 10 (2024) e27926].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41497877 β€” expression of concern
57
+ SPB-0580,10.3892/ijo.2025.5818,[Expression of Concern] Radiosensitization of esophageal carcinoma cells by knockdown of RNF2 expression.,"Yang Xing-Xiao, Ma Ming, Sang Mei-Xiang, Wang Xue-Xiao, Song Heng, et al.",2026,International journal of oncology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, it was drawn to the Editor's attention by a concerned reader that certain of the westen blot data included in Fig. 4C and D looked strikingly similar, such that the same data may have been included more than once in these figure parts to show the results of differently performed experiments. Upon performing an independent analysis of the data in the Editorial Office, it also came to light that data featured in Fig. 3A of the above paper had been re‑used in a figure in a paper featuring some of the same authors that was published in the journal Scientific Reports. The authors were contacted by the Editorial Office to offer an explanation for this possible anomaly in the presentation of the data in this paper, although up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression o",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41235657 β€” expression of concern
58
+ SPB-0581,10.1002/ijgo.70659,EXPRESSION OF CONCERN: Addition of Growth Hormone to the Microflare Stimulation Protocol among Women with Poor Ovarian Response.,Unknown,2026,International journal of gynaecology and obstetrics: the official organ of the International Federation of Gynaecology and Obstetrics,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Y. A. Bayoumi, D. M. R. Dakhly, Y. A. Bassiouny, N. M. Hashish, ""Addition of Growth Hormone to the Microflare Stimulation Protocol among Women with Poor Ovarian Response,"" International Journal of Gynecology & Obstetrics 131, no. 3 (2015): 305-308, https://doi.org/10.1016/j.ijgo.2015.05.034. This Expression of Concern is for the above article, published online on 23 August 2015, in Wiley Online Library (http://onlinelibrary.wiley.com/), and has been issued by agreement between the journal Editor-in-Chief, Michael Geary; the International Federation of Gynecology and Obstetrics; and John Wiley & Sons Ltd. Multiple third parties have reported concerns regarding the feasibility of the study reported and the validity of the data. The journal contacted the authors, who provided a response to these concerns as well as raw data for the study. Multiple expert reviewers have investigated these data. While concerns were raised about the logical feasibility of the study by some reviewers, the rev",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41236126 β€” expression of concern
59
+ SPB-0582,10.1177/03000605251408201,"Expression of concern: ""New endoscopic classification system for biliary stricture after liver transplantation"".",Unknown,2025,The Journal of international medical research,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41456182 β€” expression of concern
60
+ SPB-0583,10.1016/j.semcancer.2025.11.015,"Expression of concern ""Fungi, immunosenescence and cancer"" [Semin. Cancer Biol. 109 (2025) 67-82].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365568 β€” expression of concern
61
+ SPB-0584,10.1161/STR.0000000000000497,"Expression of Concern for: ""Isoflurane Post-Treatment Ameliorates GMH-Induced Brain Injury in Neonatal Rats"".",Unknown,2025,Stroke,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41064850 β€” expression of concern
62
+ SPB-0585,10.3892/or.2025.9004,[Expression of Concern] Combination of the FGFR4 inhibitor PD173074 and 5‑fluorouracil reduces proliferation and promotes apoptosis in gastric cancer.,"Ye Yan-Wei, Hu Shuang, Shi Ying-Qiang, Zhang Xie-Fu, Zhou Ye, et al.",2025,Oncology reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of this paper, it was drawn to the Editor's attention by a concerned reader that, for the western blot data shown in Figs. 1E and 4, the FGFR blots and control GAPDH blots were both strikingly similar in these figures, suggesting that the same data had been included in these, even though the results from differently performed experiments were intended to have been portrayed. The authors were contacted by the Editorial Office to offer an explanation for these apparent anomalies in the presentation of the data in this paper; however, up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further.Β  [Oncology Reports 30: 2777‑2784, 2013; DOI: 10.3892/or.2013.2796].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41070624 β€” expression of concern
63
+ SPB-0586,10.1007/s11248-025-00463-8,Editorial Expression of Concern: Expression of bioactive human interferon-gamma in transgenic rice cell suspension cultures.,"Chen Tzy-Li, Lin Yi-Ling, Lee Yi-Ling, Yang Ning-Sun, Chan Ming-Tsair",2025,Transgenic research,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41165886 β€” expression of concern
64
+ SPB-0587,10.1016/j.semcancer.2025.11.014,"Expression of concern ""DAMPs in immunosenescence and cancer"" [Semin. Cancer Biol. 106-107 (2024) 123-142].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365562 β€” expression of concern
65
+ SPB-0588,10.1016/j.semcancer.2025.11.020,"Expression of concern ""Brain macrophage senescence in glioma"" [Semin. Cancer Biol. 104-105 (2024) 46-60].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365572 β€” expression of concern
66
+ SPB-0589,10.3892/ijmm.2025.5680,[Expression of Concern] Β CDKN2A (p16INK4A) affects the anti‑tumor effect of CDK inhibitor in somatotroph adenomas.,"Chen Yiyuan, Li Zhenye, Fang Qiuyue, Wang Hongyun, Li Chuzhong, et al.",2026,International journal of molecular medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, it was drawn to the Editor's attention by an interested reader that, regarding the western blot data shown in Fig. 5 on p. 507, the first set of GAPDH bands for the GH3 cell line were strikingly similar to the EGFR protein bands shown for the GT1‑1 cell line in the adjacent set of gels, such that the same data had apparently been used to represent the two different proteins. The authors were contacted by the Editorial Office to offer an explanation for the apparent duplication of data in this paper, although up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [International Journal of Molecular Medicine 47: 500‑510, 2011; DOI: 10.3892/ijmm.2020",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41170735 β€” expression of concern
67
+ SPB-0590,10.1002/pros.70120,EXPRESSION OF CONCERN: Functional p53 Determines Docetaxel Sensitivity in Prostate Cancer Cells.,Unknown,2026,The Prostate,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"C. Liu, Y. Zhu, W. Lou, N. Nadiminty, X. Chen, Q. Zhou, X. B. Shi, R. W. deVere White, and A. C. Gao, ""Functional p53 Determines Docetaxel Sensitivity in Prostate Cancer Cells,"" The Prostate 73, no. 4 (2013): 418-427, https://doi.org/10.1002/pros.22583. This Expression of Concern is for the above article, published online on 19 September 2012 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Dr. Samuel Denmeade; and Wiley Periodicals LLC. A third party reported that the GAPDH band had been duplicated between Figures 4 A and 5B. This duplication was confirmed by the publisher. The authors responded to an inquiry by the publisher and stated that the GAPDH band in Figure 5B was inadvertently misplaced and duplicated from Figure 4 A. The authors also supplied images and data related to Figures 4 and 5. An evaluation of this data could not confirm that the corrected image for the GAPDH band in Figure 5B corresponded to da",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41495993 β€” expression of concern
68
+ SPB-0591,10.3892/mmr.2026.13817,[Expression of Concern] Curcumin suppresses breast tumor angiogenesis by abrogating osteopontin‑induced VEGF expression.,"Chakraborty Goutam, Jain Shalini, Kale Smita, Raja Remya, Kumar Santosh, et al.",2026,Molecular medicine reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, it was drawn to the Editor's attention by a concerned reader that the statistical analysis in this study may not have employed the most appropriate statistical tests; namely, the paired Student's t‑test was used for comparisons between independent groups, which the reader considered may have inflated the statistical significance. Neither may the paired Student's t‑test have been the most appropriate test to have been selected for various of the migration and invasion assay experiments, wherein at least three groups were being compared. Owing to the fact that the Editorial Office has been made aware of the possibility of inappropriate statistics handling in this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [Molecular Medicine Reports 1: 641‑646, 2008; DOI: 10.3892/mmr_00000005].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41645753 β€” expression of concern
69
+ SPB-0592,10.1371/journal.ppat.1013953,Expression of Concern: Effects of Capsular Polysaccharide amount on Pneumococcal-Host interactions.,Unknown,2026,PLoS pathogens,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41701677 β€” expression of concern
70
+ SPB-0593,10.1111/trf.70139,EXPRESSION OF CONCERN: Comparing Two Blood Culture Systems for the Detection of Bacterial Contamination in Platelet Concentrates.,Unknown,2026,Transfusion,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Y. Chetouane , P. Gallian , K. Chetouane , G. Dubourg , J. Chiaroni , D. Raoult , and L. Camoin-Jau , ""Comparing Two Blood Culture Systems for the Detection of Bacterial Contamination in Platelet Concentrates,"" Transfusion 58, no. 11 (2018): 2604-2610, https://doi.org/10.1111/trf.14911. This Expression of Concern is for the above article, published online on 7 October 2018 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by agreement between the journal Editor-in-Chief, Richard M. Kaufman; the Association for the Advancement of Blood & Biotherapies (AABB); and John Wiley & Sons, Inc. The Expression of Concern has been agreed due to questions raised about the study's adherence to French legal and ethical requirements for research involving human subjects. The investigation into these concerns is ongoing. Therefore, the journal has decided to issue an Expression of Concern to inform and alert readers.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41738379 β€” expression of concern
71
+ SPB-0594,10.3892/ijo.2025.5812,[Expression of Concern] Halofuginone induces the apoptosis of breast cancer cells and inhibits migration via downregulation of matrix metalloproteinase‑9.,"Jin Mei Ling, Park Sun Young, Kim Young Hun, Park Geuntae, Lee Sang Joon",2025,International journal of oncology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, it was drawn to the Editor's attention by a concerned reader that, for the cell invasion assay experiments shown in Fig. 4B, the 'Con' and 'LPA+HF' data panels contained an overlapping section, such that data which were intended to show the results of differently performed experiments appeared to have been derived from the same original source. The authors were contacted by the Editorial Office to offer an explanation for this possible anomaly in the presentation of the data in this paper, although up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further.Β  [International Journal of Oncology 44: 309‑318, 2014; DOI: 10.3892/ijo.2013.2157].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41104888 β€” expression of concern
72
+ SPB-0595,10.3892/mmr.2025.13679,[Expression of Concern] Resveratrol improves neurological outcome and neuroinflammation following spinal cord injury through enhancing autophagy involving the AMPK/mTOR pathway.,"Meng Hong-Yu, Shao De-Cheng, Li Han, Huang Xiao-Dan, Yang Guang, et al.",2025,Molecular medicine reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of this paper, it was drawn to the Editor's attention by a concerned reader that, regarding the confocal microscopic images shown in Fig 3, the top (Sham) and bottom (SCI) data panels appeared to show a small section of overlapping data, such that data which were intended to show the results from differently performed experiments had apparently been derived from the same original source. The authors were contacted by the Editorial Office to offer an explanation for this apparent anomaly in the presentation of the data in this paper; however, up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [Molecular Medicine Reports 18: 2237‑2244, 2018; DOI: 10.3892/mmr.2018.9",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:40937569 β€” expression of concern
73
+ SPB-0596,10.1161/STR.0000000000000496,"Expression of Concern for: ""Nasal Administration of Recombinant Osteopontin Attenuates Early Brain Injury After Subarachnoid Hemorrhage"".",Unknown,2025,Stroke,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41064907 β€” expression of concern
74
+ SPB-0597,10.1002/mbo3.70246,EXPRESSION OF CONCERN: Genomic and Phenotypic Description of the Newly Isolated Human Species Collinsella bouchesdurhonensis sp. nov.,Unknown,2026,MicrobiologyOpen,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"M. Bilen, M. Beye, M.D.M. Fonkou, S. Khelaifia, F. Cadoret, N. Armstrong, T.T. Nguyen, J. Delerce, Z. Daoud, D. Raoult, and P.-E. Fournier, ""Genomic and Phenotypic Description of the Newly Isolated Human Species Collinsella bouchesdurhonensis sp. nov.,"" Microbiology Open 7, no. 5 (2018): e00580, https://doi.org/10.1002/mbo3.580. This Expression of Concern is for the above article, published online on 13 June 2018 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by John Wiley & Sons Ltd. The Expression of Concern has been agreed due to questions raised about the study's adherence to French legal and ethical requirements for research involving human subjects, including questions regarding proper informed consent for research involving vulnerable populations. A question has also been raised by a third party about whether this particular article by the authors reports a ""newly isolated"" microbial strain that is in fact a ""novel species."" The investigation into all of t",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41711413 β€” expression of concern
75
+ SPB-0598,10.1021/acsomega.5c11089,"Expression of Concern for ""Enhanced Bactericidal Action of rGO-ZnO Hybrids Prepared by the One-Pot Co-precipitation Approach"".","Usman Osama, Ikram Muhammad, Abid Namra, Saeed Mohsin, Bashir Aneeqa, et al.",2025,ACS omega,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41536562 β€” expression of concern
76
+ SPB-0599,10.1016/j.heliyon.2025.e44219,"Expression of concern: ""Hilfer-Katugampola fractional stochastic differential inclusions with Clarke sub-differential"" [Heliyon 10 (2024) e29667].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41497868 β€” expression of concern
77
+ SPB-0600,10.1161/STR.0000000000000509,"Expression of Concern for: ""UDP-Glucose/P2Y14 Receptor Signaling Exacerbates Neuronal Apoptosis After Subarachnoid Hemorrhage in Rats"".",Unknown,2025,Stroke,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41064853 β€” expression of concern
78
+ SPB-0601,10.1016/j.semcancer.2025.11.021,"Expression of concern ""Gut microbiota and immunosenescence in cancer"" [Semin Cancer Biol. 104-105 (2024) 32-45].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365575 β€” expression of concern
79
+ SPB-0602,10.1016/j.semcancer.2025.11.012,"Expression of concern ""Ferroptosis and immunosenescence in colorectal cancer"" [Semin. Cancer Biol. 106-107 (2024) 156-165].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365566 β€” expression of concern
80
+ SPB-0603,10.1016/j.heliyon.2025.e44229,"Expression of concern: ""TL1A promotes metastasis and EMT process of colorectal cancer"" [Heliyon 10 (2024) e24392].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41497879 β€” expression of concern
81
+ SPB-0604,10.1093/eurheartjsupp/suaf080,Expression of Concern: May Measurement Month 2022: an analysis of blood pressure screening results from Malawi.,Unknown,2025,European heart journal supplements : journal of the European Society of Cardiology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:40980009 β€” expression of concern
82
+ SPB-0605,10.3892/ijo.2025.5802,"[Expression of Concern] miR‑135b, upregulated in breast cancer, promotes cell growth and disrupts the cell cycle by regulating LATS2.","Hua Kaiyao, Jin Jiali, Zhao Junyong, Song Jialu, Song Hongming, et al.",2025,International journal of oncology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of the above paper, it was drawn to the Editor's attention by a concerned reader that, for the scratch‑wound assay data shown in Fig. 4A, the '24 h/MCF‑7/mir‑135b inhibitor' data panel looked strikingly similar to the '24 h/MCF‑7/NC' data panel in Fig. 10C, albeit the panels were featured rotated through 180Β° with respect to each other. The authors were contacted by the Editorial Office to offer an explanation for this possible anomaly in the presentation of the data in this paper, although up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [International Journal of Oncology 48: 1997‑2006, 2016; DOI: 10.3892/ijo.2016.3405].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:40970336 β€” expression of concern
83
+ SPB-0606,10.1093/ntr/ntaf214,Expression of Concern: Critical Appraisal of Animal Studies Assessing Risk of Heated Tobacco Products-A Systematic Review.,Unknown,2025,Nicotine & tobacco research : official journal of the Society for Research on Nicotine and Tobacco,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41148000 β€” expression of concern
84
+ SPB-0607,10.1371/journal.pone.0339143,Expression of Concern: Contribution of labor related gene subtype classification on heterogeneity of polycystic ovary syndrome.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41411237 β€” expression of concern
85
+ SPB-0608,10.1039/d5ob90166a,Expression of Concern: Total synthesis of the natural product EBC-329.,"Thombal Raju S, Jadhav Vrushali H",2026,Organic & biomolecular chemistry,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Expression of Concern for 'Total synthesis of the natural product EBC-329' by Raju S. Thombal and Vrushali H. Jadhav, Org. Biomol. Chem., 2015, 13, 9485-9491, https://doi.org/10.1039/C5OB01276G.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41510987 β€” expression of concern
86
+ SPB-0609,10.1371/journal.pone.0339147,Expression of concern: CD117 expression in fibroblasts-like stromal cells indicates unfavorable clinical outcomes in ovarian carcinoma patients.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41411281 β€” expression of concern
87
+ SPB-0610,10.1371/journal.pone.0339094,Expression of Concern: Evaluation of CA125 in relation to pain symptoms among adolescents and young adult women with and without surgically-confirmed endometriosis.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41411285 β€” expression of concern
88
+ SPB-0611,10.1161/STR.0000000000000510,"Expression of Concern for: ""Mechanisms of Osteopontin-Induced Stabilization of Blood-Brain Barrier Disruption After Subarachnoid Hemorrhage in Rats"".",Unknown,2025,Stroke,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41064902 β€” expression of concern
89
+ SPB-0612,10.1007/s00404-026-08375-6,Editorial Expression of Concern: Maternal serum leptin as a marker of preeclampsia.,"El Shahat Amal Mohamed, Ahmed Abeer Bahaa, Ahmed Magdy Refaat, Mohamed Heba Saber",2026,Archives of gynecology and obstetrics,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41814081 β€” expression of concern
90
+ SPB-0613,10.1039/d5fo90103k,Expression of concern: Ingestion of <i>Bifidobacterium longum</i> subspecies <i>infantis</i> strain CCFM687 regulated emotional behavior and the central BDNF pathway in chronic stress-induced depressive mice through reshaping the gut microbiota.,"Tian Peijun, Zou Renying, Song Linhong, Zhang Xu, Jiang Bin, et al.",2025,Food & function,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Expression of concern for 'Ingestion of Bifidobacterium longum subspecies infantis strain CCFM687 regulated emotional behavior and the central BDNF pathway in chronic stress-induced depressive mice through reshaping the gut microbiota' by Peijun Tian et al., Food Funct., 2019, 10, 7588-7598, https://doi.org/10.1039/C9FO01630A.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41230771 β€” expression of concern
91
+ SPB-0614,10.1007/s10787-025-02028-x,Editorial Expression of Concern: Proteoglycans isolated from the bramble shark cartilage show potential anti-osteoarthritic properties.,"Ajeeshkumar Kizhakkeppurath Kumaran, Vishnu Kalladath Venugopal, Navaneethan Raju, Raj Kumar, Remyakumari Kuttipurath Raghavan, et al.",2025,Inflammopharmacology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41207931 β€” expression of concern
92
+ SPB-0615,10.3892/mmr.2025.13774,[Expression of Concern] lncRNA FLVCR1‑AS1 drives colorectal cancer progression via modulation of the miR‑381/RAP2A axis.,"Han Yi, Wang Xiaoyan, Mao Enqiang, Shen Boyong, Huang Liang",2026,Molecular medicine reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"Following the publication of this paper, it was drawn to the Editor's attention by a concerned reader that, regarding the TUNEL assay experiments shown in Fig. 2E on p. 5, the Merge panels for the shFLVCR1‑AS1 experiments shown for the Caco‑2 and SW480 cell lines appeared to have been inserted into this figure the wrong way around. The authors were contacted by the Editorial Office to offer an explanation for this apparent anomaly in the presentation of the data in this paper; however, up to this time, no response from them has been forthcoming. Owing to the fact that the Editorial Office has been made aware of potential issues surrounding the scientific integrity of this paper, we are issuing an Expression of Concern to notify readers of this potential problem while the Editorial Office continues to investigate this matter further. [Molecular Medicine Reports 23: 139, 2021; DOI: 10.3892/mmr.2020.11778].",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41384308 β€” expression of concern
93
+ SPB-0616,10.1002/mbo3.70250,EXPRESSION OF CONCERN: Taxonogenomics Description of Parabacteroides timonensis sp. nov. Isolated from a Human Stool Sample.,Unknown,2026,MicrobiologyOpen,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"M. Bilen, M.D.M. Fonkou, S. Khelaifia, E. Tomei, F. Cadoret, Z. Daoud, N. Armstrong, F. Bittar, P.-E. Fournier, D. Raoult, and G. Dubourg, ""Taxonogenomics Description of Parabacteroides timonensis sp. nov. Isolated from a Human Stool Sample,"" Microbiology Open 8, no. 4 (2019): e00702, https://doi.org/10.1002/mbo3.702. This Expression of Concern is for the above article, published online on 11 October 2018 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by John Wiley & Sons Ltd. The Expression of Concern has been agreed due to questions raised about the study's adherence to French legal and ethical requirements for research involving human subjects, including questions regarding proper informed consent for research involving vulnerable populations. The investigation into these concerns is ongoing. Therefore, the journal has decided to issue an Expression of Concern to inform and alert the readers.",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41711401 β€” expression of concern
94
+ SPB-0617,10.1093/carcin/bgag010,Expression of Concern: The promoting effects of hsa_circ_0050102 in pancreatic cancer and the molecular mechanism by targeting miR-1182/NPSR1.,Unknown,2025,Carcinogenesis,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41847876 β€” expression of concern
95
+ SPB-0618,10.1177/14230380251411266,"Expression of concern: ""Prognostic value of preoperative peripheral monocyte count in patients with hepatocellular carcinoma after liver transplantation"".",Unknown,2025,Tumour biology : the journal of the International Society for Oncodevelopmental Biology and Medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41439808 β€” expression of concern
96
+ SPB-0619,10.1016/j.heliyon.2025.e44227,"Expression of concern: ""Factors affecting timing of surgery following neoadjuvant chemoradiation for esophageal cancer"" [Heliyon 9 (2023) e23212].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41497878 β€” expression of concern
97
+ SPB-0620,10.1016/j.heliyon.2025.e44223,"Expression of concern: ""Elderly users' perceptions of signage systems from tertiary hospitals in Guangzhou"" [Heliyon 10 (2024) e25003].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41497869 β€” expression of concern
98
+ SPB-0621,10.1002/mbo3.70251,"EXPRESSION OF CONCERN: Noncontiguous Finished Genome Sequence and Description of Raoultibacter massiliensis gen. nov., sp. nov. and Raoultibacter timonensis sp. nov, Two New Bacterial Species Isolated from the Human Gut.",Unknown,2026,MicrobiologyOpen,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"S.I. Traore, M. Bilen, M. Beye, A. Diop, M.D.M. Fonkou, M.L. Tall, C. Michelle, M. Yasir, E.I. Azhar, F. Bibi, F. Bittar, A.A. Jiman-Fatani, Z. Daoud, F. Cadoret, P.-E. Fournier, and S. Edouard, ""Noncontiguous Finished Genome Sequence and Description of Raoultibacter massiliensis gen. nov., sp. nov. and Raoultibacter timonensis sp. nov, Two New Bacterial Species Isolated from the Human Gut,"" Microbiology Open 8, no. 6 (2019): e00758, https://doi.org/10.1002/mbo3.758. This Expression of Concern is for the above article, published online on 30 January 2019 in Wiley Online Library (wileyonlinelibrary.com), and has been issued by John Wiley & Sons Ltd. The Expression of Concern has been agreed due to questions raised about the study's adherence to French legal and ethical requirements for research involving human subjects, including questions regarding proper informed consent for research involving vulnerable populations. Additionally, a third party noted that the two bacterial strains rep",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41711400 β€” expression of concern
99
+ SPB-0622,10.1371/journal.pone.0339083,Expression of Concern: Utilization of complementary and alternative medicine (CAM) by women with breast cancer or gynecological cancer.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41411277 β€” expression of concern
100
+ SPB-0623,10.1108/IJHCQA-10-2025-0172,Expression of concern: Role of a hospital accreditation program in developing a process management system: a qualitative study.,Unknown,2025,International journal of health care quality assurance,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41367258 β€” expression of concern
101
+ SPB-0624,10.1016/j.fertnstert.2025.07.034,Expression of concern 'Effect of high environmental temperature on semen parameters among fertile men' [Fertil Steril 2010; 93:1884-86].,"Momen M Nabil, Ananian Fredrick B, Fahmy Ibrahim M, Mostafa Taymour",2025,Fertility and sterility,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41052828 β€” expression of concern
102
+ SPB-0625,10.1021/acsomega.5c11088,"Expression of Concern for ""Facile Synthesis of Vanadium Oxide/Carbon Spheres-Doped Nickel Oxide Functioned as a Nanocatalyst and Bactericidal Behavior with Molecular Docking Analysis"".","Baz Shair, Ikram Muhammad, Haider Ali, Shahzadi Anum, Ul-Hamid Anwar, et al.",2025,ACS omega,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41536560 β€” expression of concern
103
+ SPB-0626,10.1016/j.heliyon.2025.e44029,"Expression of concern: ""Assessing the effect of MitoQ<sub>10</sub> and Vitamin D3 on ovarian oxidative stress, steroidogenesis and histomorphology in DHEA induced PCOS mouse model"" [Heliyon 6 (2020) e04279].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41395093 β€” expression of concern
104
+ SPB-0627,10.1016/j.semcancer.2025.11.016,"Expression of concern ""Mechanisms and strategies of immunosenescence effects on non-small cell lung cancer (NSCLC) treatment: A comprehensive analysis and future directions"" [Semin. Cancer Biol. 109 (2025) 44-66].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365565 β€” expression of concern
105
+ SPB-0628,10.1042/BSR20191000_EOC,Expression of Concern: The Protective Role of MiR-206 in Regulating Cardiomyocytes Apoptosis Induced by Ischemic Injury by Targeting PTP1B.,"Yan Yejun, Dang Hongwei, Zhang Xin, Wang Xia, Liu Xiaodong",2025,Bioscience reports,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41065330 β€” expression of concern
106
+ SPB-0629,10.1016/j.heliyon.2025.e44040,"Expression of concern: ""Comparing the impact of personal trainer guidance to exercising with others: Determining the optimal approach"" [Heliyon 10 (2024) e24625].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41395094 β€” expression of concern
107
+ SPB-0630,10.1136/bmjopen-2017-021122eoc,<i>Expression of concern:</i> The validity of the Rx-Risk comorbidity index using medicines mapped to the anatomical therapeutic chemical (ATC) Classification System.,Unknown,2025,BMJ open,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41130706 β€” expression of concern
108
+ SPB-0631,10.1371/journal.pone.0339096,Expression of Concern: Risk assessment of shoulder dystocia via the difference between transverse abdominal and biparietal diameters: A retrospective observational cohort study.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41411252 β€” expression of concern
109
+ SPB-0632,10.1371/journal.pone.0339113,Expression of Concern: First identification of resident and circulating fibrocytes in Dupuytren's disease shown to be inhibited by serum amyloid P and Xiapex.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41406111 β€” expression of concern
110
+ SPB-0633,10.1016/j.semcancer.2025.11.003,"Expression of concern ""Navigating the paradox of senescence and chemoresistance in pancreatic cancer"" [Semin. Cancer Biol. 114 (2025) 60-72].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365574 β€” expression of concern
111
+ SPB-0634,10.1371/journal.pone.0335988,Expression of Concern: Ubiquitination and Degradation of Ribonucleotide Reductase M1 by the Polycomb Group Proteins RNF2 and Bmi1 and Cellular Response to Gemcitabine.,Unknown,2025,PloS one,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41187110 β€” expression of concern
112
+ SPB-0635,10.1002/1873-3468.70258,EXPRESSION OF CONCERN: Circadian Gene Clock Contributes to Cell Proliferation and Migration of Glioma and Is Directly Regulated by Tumor-Suppressive miR-124.,Unknown,2026,FEBS letters,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,"A. Li, X. Lin, X. Tan, B. Yin, W. Han, J. Zhao, J. Yuan, B. Qiang, and X. Peng, ""Circadian Gene Clock Contributes to Cell Proliferation and Migration of Glioma and Is Directly Regulated by Tumor-Suppressive miR-124,"" FEBS Letters 587, no. 15 (2013): 2455-2460, https://doi.org/10.1016/j.febslet.2013.06.018. This Expression of Concern is for the above article, published online on 19 June 2013, in Wiley Online Library (http://onlinelibrary.wiley.com/), and has been issued by agreement between the journal Editor-in-Chief, Michael Brunner; FEBS Press; and John Wiley and Sons Ltd. A third party reported that the siNC and siCLOCK images for U87MG cells in Figure 2D shared an overlapping section. An investigation by the journal confirmed these concerns. The authors responded to an inquiry by the journal and supplied what were labeled as original data and a request for correction. The authors stated that the error in Figure 2D was caused by a mistake in the image compilation process. The journa",,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41432165 β€” expression of concern
113
+ SPB-0636,10.1016/j.heliyon.2025.e43938,"Expression of concern: ""Central giant cell granuloma of the mandibular condyle: A rare case and a literature review"" [Heliyon 6 (2020) e03085].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41395100 β€” expression of concern
114
+ SPB-0637,10.7759/cureus.x59,Expression of Concern: Comparing the Efficacy and Safety of Dexmedetomidine Versus Propofol for Sedation in Adult Patients Undergoing Cardiac Procedures: A Systematic Review.,"Chowdhury Shafayath, Sawires John, Weissman Brandon, Saju Sera, Lambroussis Constantino G",2025,Cureus,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41278080 β€” expression of concern
115
+ SPB-0638,10.1016/j.heliyon.2025.e43785,"Expression of concern: ""Characterizing tensile and flexural properties of synthetic fibers-reinforced epoxy composite for foot prosthetic application"" [Heliyon 10 (2024) e38471].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41215953 β€” expression of concern
116
+ SPB-0639,10.1186/s13287-025-04787-4,Editorial Expression of Concern: Prevention of multiple system atrophy using human bone marrow-derived mesenchymal stem cells by reducing polyamine and cholesterol-induced neural damages.,"Park Kyung-Ran, Hwang Chul Ju, Yun Hyung-Mun, Yeo In Jun, Choi Dong-Young, et al.",2025,Stem cell research & therapy,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41199414 β€” expression of concern
117
+ SPB-0640,10.1007/s10456-025-10014-8,Editorial Expression of Concern: The nuclear translocation of endostatin is mediated by its receptor nucleolin in endothelial cells.,"Song Nan, Ding Yanping, Zhuo Wei, He Ting, Fu Zhiguang, et al.",2025,Angiogenesis,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41108432 β€” expression of concern
118
+ SPB-0641,10.1172/JCI198646,Expression of Concern for AIP1 functions as an endogenous inhibitor of VEGFR2-mediated signaling and inflammatory angiogenesis in mice.,"Zhang Haifeng, He Yun, Dai Shengchuan, Xu Zhe, Luo Yan, et al.",2025,The Journal of clinical investigation,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:40955656 β€” expression of concern
119
+ SPB-0642,10.1161/STR.0000000000000507,"Expression of Concern for: ""CCR5 Activation Promotes NLRP1-Dependent Neuronal Pyroptosis via CCR5/PKA/CREB Pathway After Intracerebral Hemorrhage"".",Unknown,2025,Stroke,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41064906 β€” expression of concern
120
+ SPB-0643,10.1016/j.semcancer.2025.11.007,"Expression of concern ""Psychological stress on cancer progression and immunosenescence"" [Semin. Cancer Biol. 113 (2025) 85-99].",Unknown,2025,Seminars in cancer biology,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41365564 β€” expression of concern
121
+ SPB-0644,10.1016/j.heliyon.2025.e43960,"Expression of concern: ""Ferroptosis in diabetic cardiomyopathy: Advances in cardiac fibroblast-cardiomyocyte interactions"" [Heliyon 10 (2024) e35219].",Unknown,2025,Heliyon,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41395089 β€” expression of concern
122
+ SPB-0645,10.1126/science.1179052,Bacterium that can grow by using arsenic instead of phosphorus,"Wolfe-Simon F, Blum J, Kulp T, et al.",2010,Science,borderline_manual,BORDERLINE,0,0.55,"METH-01, STAT-01",,Heavily disputed methodology β€” arsenic life claims not replicated,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.55,,,biology,manual_verified,scipeerai_collector_v1,NASA arsenic life paper β€” disputed but not retracted
123
+ SPB-0646,10.1016/j.socscimed.2010.05.004,Influence of life stress on depression: moderation by polymorphism,"Caspi A, Sugden K, Moffitt T, et al.",2003,Science,borderline_manual,BORDERLINE,0,0.5,"STAT-04, STAT-05",,Failed to replicate in multiple large studies β€” effect size disputed,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.5,,,psychology,manual_verified,scipeerai_collector_v1,5-HTT gene x stress interaction β€” replication crisis poster child
124
+ SPB-0647,10.1038/ncomms4019,Experimental evidence of massive-scale emotional contagion through social networks,"Kramer A, Guillory J, Hancock J",2014,PNAS,borderline_manual,BORDERLINE,0,0.58,METH-03,,Ethical concerns β€” conducted without informed consent,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.58,,,psychology,manual_verified,scipeerai_collector_v1,Facebook emotional contagion study β€” ethics controversy
125
+ SPB-0648,10.1073/pnas.1218455110,Female hurricanes are deadlier than male hurricanes,"Jung K, Shavitt S, Viswanathan M, Hilbe J",2014,PNAS,borderline_manual,BORDERLINE,0,0.6,"STAT-01, METH-01",,"Methodological flaws β€” data cherry-picked, statistical errors found",,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.6,,,psychology,manual_verified,scipeerai_collector_v1,Widely criticized statistical approach
126
+ SPB-0649,10.1126/science.1255484,Estimating the reproducibility of psychological science,Open Science Collaboration,2015,Science,borderline_manual,BORDERLINE,0,0.1,,,Itself clean but triggered replication crisis debate,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.1,,,psychology,manual_verified,scipeerai_collector_v1,Reproducibility Project β€” BORDERLINE as comparison point
127
+ SPB-0650,10.1038/s41562-018-0399-z,"Many analysts, one dataset: making transparent how variations in teams affect results","Silberzahn R, Uhlmann E, Martin D, et al.",2018,Nature Human Behaviour,borderline_manual,BORDERLINE,0,0.35,"STAT-01, STAT-02",,Highlights analytical flexibility β€” same data different results,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.35,,,psychology,manual_verified,scipeerai_collector_v1,Multiverse analysis β€” methodological concern paper
128
+ SPB-0651,10.1177/0956797611417632,False-positive psychology: undisclosed flexibility in data collection,"Simmons J, Nelson L, Simonsohn U",2011,Psychological Science,borderline_manual,BORDERLINE,0,0.4,STAT-01,,Demonstrates p-hacking is widespread β€” borderline by design,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,,,psychology,manual_verified,scipeerai_collector_v1,p-hacking demonstration paper
129
+ SPB-0652,10.1016/j.paid.2010.01.011,Priming effects of television food advertising on eating behavior,"Harris J, Bargh J, Brownell K",2009,Health Psychology,borderline_manual,BORDERLINE,0,0.52,"STAT-04, METH-02",,Priming effects generally failed to replicate post-2015,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.52,,,psychology,manual_verified,scipeerai_collector_v1,Replication crisis β€” priming literature
data/scipeerai_bench/scipeerai_bench_clean.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/scipeerai_bench/scipeerai_bench_fraud.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/scipeerai_bench/scipeerai_bench_sample.csv ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ paper_id,doi,title,authors,year,journal,source_db,category,is_fraud,fraud_confidence,fraud_types,retraction_date,retraction_reason,pubpeer_url,stat_audit_score,figure_forensics_score,methodology_score,citation_score,reproducibility_score,novelty_score,grim_score,sprite_score,granularity_score,pcurve_score,effect_size_score,retraction_score,cartel_score,llm_score,overall_risk_score,abstract_text,full_text_path,field_of_study,labeling_method,labeled_by,notes
2
+ SPB-0001,10.1159/000546362,Fenbendazole as an Anticancer Agent? A Case Series of Self-Administration in Three Patients.,"Makis William, Baghli Ilyes, Martinez Pierrick",2025,Case reports in oncology,pubmed_retracted,CONFIRMED_FRAUD,1,0.7,FAB-01,,Retraction notice found in PubMed,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.7,"Fenbendazole (FBZ), an inexpensive and widely accessible antiparasitic drug used in veterinary medicine, has garnered growing interest for its potential as an anticancer therapy. Preclinical studies suggest that FBZ exerts its anticancer effects through a wide variety of mechanisms. While FBZ has shown promise both in vitro and in vivo studies, clinical evidence supporting its use and efficacy in treating metastatic cancer is currently limited. This report highlights 3 cases of patients with advanced cancer - including breast, prostate, and melanoma. Two patients achieved complete remission, and one achieved near-complete remission after incorporating FBZ into their treatment regimens alongside other therapies (excluding chemotherapy). All three patients tolerated FBZ without any reported adverse effects, and remission was sustained during follow-up periods ranging from 11 months to nearly 3 years. FBZ demonstrates potential as a novel promising therapeutic option for repurposing in on",,,auto_pubmed,scipeerai_collector_v1,pmid:40605964
3
+ SPB-0002,10.1038/s41564-025-01985-x,Metabolic remodelling produces fumarate via the aspartate-argininosuccinate shunt in macrophages as an antiviral defence.,"Xia Wenjun, Mao Youxiang, Xia Ziyan, Cheng Jie, Jiang Peng",2025,Nature microbiology,pubmed_retracted,CONFIRMED_FRAUD,1,0.7,FAB-01,,Retraction notice found in PubMed,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.7,"Metabolic remodelling underpins macrophage effector functions in response to various stimuli, but the mechanisms involved are unclear. Here we report that viral-infection-induced inflammatory stimulation causes a rewiring of the urea cycle and the tricarboxylic acid cycle metabolism in macrophages to form a cyclic pathway called the aspartate-argininosuccinate (AAS) shunt. Using RNA sequencing, unbiased metabolomics and stable isotope tracing, we found that fumarate generated from the AAS shunt is driven by argininosuccinate synthase (ASS1) in the cytosol and potentiates inflammatory effects. Genetic ablation of ASS1 reduces intracellular fumarate levels and interferon-Ξ² production, and mitochondrial respiration is also suppressed. Notably, viral challenge or fumarate esters enhance interferon-Ξ² production via direct succination of the mitochondrial antiviral signalling protein and activation of the retinoic acid-inducible gene-I-like receptor signalling. In addition to the vesicular s",,,auto_pubmed,scipeerai_collector_v1,pmid:40251448
4
+ SPB-0003,10.1016/j.jinf.2025.106530,REMOVED: Ivermectin and COVID-19.,"Butler Christopher C, Hobbs F D Richard, Little Paul, Richards Duncan, Saville Benjamin R, et al.",2025,The Journal of infection,pubmed_retracted,CONFIRMED_FRAUD,1,0.7,FAB-01,,Retraction notice found in PubMed,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.7,,,,auto_pubmed,scipeerai_collector_v1,pmid:40494426
5
+ SPB-0288,10.3390/nu14020348,The Role of Diet in Prognosis among Cancer Survivors: A Systematic Review and Meta-Analysis of Dietary Patterns and Diet Interventions.,"Castro-Espin Carlota, Agudo Antonio",2022,Nutrients,pubmed_clean,CLEAN,0,0.05,,,,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.05,"Cancer survival continues to improve in high-income countries, partly explained by advances in screening and treatment. Previous studies have mainly examined the relationship between individual dietary components and cancer prognosis in tumours with good therapeutic response (breast, colon and prostate cancers). The aim of this review is to assess qualitatively (and quantitatively where appropriate) the associations of dietary patterns and cancer prognosis from published prospective cohort studies, as well as the effect of diet interventions by means of randomised controlled trials (RCT). A systematic search was conducted in PubMed, and a total of 35 prospective cohort studies and 14 RCT published between 2011 and 2021 were selected. Better overall diet quality was associated with improved survival among breast and colorectal cancer survivors; adherence to the Mediterranean diet was associated to lower risk of mortality in colorectal and prostate cancer survivors. A meta-analysis using",,medicine,auto_pubmed_clean,scipeerai_collector_v1,pmid:35057525
6
+ SPB-0289,10.1161/CIRCULATIONAHA.121.056355,Direct Oral Anticoagulants Versus Warfarin in Patients With Atrial Fibrillation: Patient-Level Network Meta-Analyses of Randomized Clinical Trials With Interaction Testing by Age and Sex.,"Carnicelli Anthony P, Hong Hwanhee, Connolly Stuart J, Eikelboom John, Giugliano Robert P, et al.",2022,Circulation,pubmed_clean,CLEAN,0,0.05,,,,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.05,"Direct oral anticoagulants (DOACs) are preferred over warfarin for stroke prevention in atrial fibrillation. Meta-analyses using individual patient data offer substantial advantages over study-level data. We used individual patient data from the COMBINE AF (A Collaboration Between Multiple Institutions to Better Investigate Non-Vitamin K Antagonist Oral Anticoagulant Use in Atrial Fibrillation) database, which includes all patients randomized in the 4 pivotal trials of DOACs versus warfarin in atrial fibrillation (RE-LY [Randomized Evaluation of Long-Term Anticoagulation Therapy], ROCKET AF [Rivaroxaban Once Daily Oral Direct Factor Xa Inhibition Compared With Vitamin K Antagonism for Prevention of Stroke and Embolism Trial in Atrial Fibrillation], ARISTOTLE [Apixaban for Reduction in Stroke and Other Thromboembolic Events in Atrial Fibrillation], and ENGAGE AF-TIMI 48 [Effective Anticoagulation With Factor Xa Next Generation in Atrial Fibrillation-Thrombolysis in Myocardial Infarction",,medicine,auto_pubmed_clean,scipeerai_collector_v1,pmid:34985309
7
+ SPB-0290,10.1037/ocp0000146,A systematic review and meta-analysis of workplace mindfulness training randomized controlled trials.,"Bartlett Larissa, Martin Angela, Neil Amanda L, Memish Kate, Otahal Petr, et al.",2019,Journal of occupational health psychology,pubmed_clean,CLEAN,0,0.05,,,,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.05,"This meta-analytic review responds to promises in the research literature and public domain about the benefits of workplace mindfulness training. It synthesizes randomized controlled trial evidence from workplace-delivered training for changes in mindfulness, stress, mental health, well-being, and work performance outcomes. Going beyond extant reviews, this article explores the influence of variability in workforce and intervention characteristics for reducing perceived stress. Meta-effect estimates (Hedge's g) were computed using data from 23 studies. Results indicate beneficial effects following training for mindfulness (g = 0.45, p < .001) and stress (g = 0.56, p < .001), anxiety (g = 0.62, p < .001) and psychological distress (g = 0.69, p < .001), and for well-being (g = 0.46, p = .002) and sleep (g = 0.26, p = .003). No conclusions could be drawn from pooled data for burnout due to ambivalence in results, for depression due to publication bias, or for work performance due to insuf",,medicine,auto_pubmed_clean,scipeerai_collector_v1,pmid:30714811
8
+ SPB-0511,10.1038/nature14539,Human-level control through deep reinforcement learning,"Mnih V, Kavukcuoglu K, Silver D, et al.",2015,Nature,elite_manual,BASELINE_ELITE,0,0.01,,,,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.01,,,computer science,manual_verified,scipeerai_collector_v1,DeepMind DQN β€” most cited RL paper
9
+ SPB-0512,10.1145/3065386,ImageNet classification with deep convolutional neural networks,"Krizhevsky A, Sutskever I, Hinton G",2012,Communications of the ACM,elite_manual,BASELINE_ELITE,0,0.01,,,,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.01,,,computer science,manual_verified,scipeerai_collector_v1,AlexNet β€” launched deep learning era
10
+ SPB-0521,10.1159/000548698,Expression of Concern.,Unknown,2025,Neuroimmunomodulation,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41363674 β€” expression of concern
11
+ SPB-0522,10.1177/09287329251392360,Expression of concern.,Unknown,2025,Technology and health care : official journal of the European Society for Engineering and Medicine,pubmed_expression_of_concern,SUSPECTED_FRAUD,0,0.62,STAT-01,,Expression of concern issued by journal,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.4,0.0,0.0,0.62,,,,auto_pubmed_eoc,scipeerai_collector_v1,pmid:41223024 β€” expression of concern
12
+ SPB-0630,10.1126/science.1179052,Bacterium that can grow by using arsenic instead of phosphorus,"Wolfe-Simon F, Blum J, Kulp T, et al.",2010,Science,borderline_manual,BORDERLINE,0,0.55,"METH-01, STAT-01",,Heavily disputed methodology β€” arsenic life claims not replicated,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.55,,,biology,manual_verified,scipeerai_collector_v1,NASA arsenic life paper β€” disputed but not retracted
13
+ SPB-0631,10.1016/j.socscimed.2010.05.004,Influence of life stress on depression: moderation by polymorphism,"Caspi A, Sugden K, Moffitt T, et al.",2003,Science,borderline_manual,BORDERLINE,0,0.5,"STAT-04, STAT-05",,Failed to replicate in multiple large studies β€” effect size disputed,,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.5,,,psychology,manual_verified,scipeerai_collector_v1,5-HTT gene x stress interaction β€” replication crisis poster child
data/scipeerai_bench/scipeerai_bench_v1.1.csv ADDED
The diff for this file is too large to render. See raw diff
 
data/scipeerai_bench/scipeerai_bench_v1.csv ADDED
The diff for this file is too large to render. See raw diff
 
main.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from src.scipeerai.api import create_app
2
+
3
+ app = create_app()
4
+
5
+ if __name__ == "__main__":
6
+ import uvicorn
7
+ uvicorn.run(app, host="0.0.0.0", port=8000)
models/model_config_honest.json ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_type": "LogisticRegression",
3
+ "version": "1.0.0",
4
+ "threshold": 0.35,
5
+ "features": [
6
+ "score_stat",
7
+ "score_method",
8
+ "score_citation",
9
+ "score_repro",
10
+ "score_novelty",
11
+ "score_grim",
12
+ "score_sprite",
13
+ "score_granularity",
14
+ "score_pcurve",
15
+ "score_effect",
16
+ "score_retraction",
17
+ "score_cartel",
18
+ "score_llm",
19
+ "score_fraud",
20
+ "score_temporal",
21
+ "score_dna",
22
+ "score_dataprint",
23
+ "score_peerreview",
24
+ "score_spectrum",
25
+ "feat_n_nonzero",
26
+ "feat_integrity_index"
27
+ ],
28
+ "n_features": 21,
29
+ "cv_auc_mean": 0.6023,
30
+ "cv_auc_std": 0.0434,
31
+ "full_auc": 0.6465,
32
+ "full_f1": 0.5858,
33
+ "full_recall": 0.9389,
34
+ "trained_on": "SciPeerBench v1.1 (444 papers)",
35
+ "honest_eval": true,
36
+ "note": "No year/field leakage. Honest holdout evaluation."
37
+ }
models/replication_predictor_honest.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f27183f56dc9fd61cee53f9ee7026f7ace525816d5ecf2f1d9d1eb6e1a4ccdaf
3
+ size 1766
models/v2/discovery1_linguistic_divergence.csv ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ feature,fraud_mean,clean_mean,divergence_pct
2
+ vocab_diversity,0.687,0.7219,4.84
3
+ burstiness,0.4136,0.4028,2.67
4
+ sentence_uniformity,0.5864,0.5972,1.8
5
+ ai_phrase_density,0.0015,0.0018,13.73
6
+ round_number_ratio,0.1325,0.1302,1.77
7
+ methodology_score,0.0085,0.014,39.36
8
+ repro_positive,0.0016,0.0074,78.33
9
+ number_density,0.0263,0.0201,30.87
10
+ benford_deviation,0.0704,0.0725,2.88
11
+ integrity_index,0.4948,0.4958,0.21
models/v2/discovery3_temporal_drift.csv ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ period,n_papers,fraud_papers,auc,fraud_vocab,clean_vocab,fraud_ai
2
+ 2000-2005,1462,1462,,0.6942,,0.001037
3
+ 2006-2010,2491,2491,,0.6978,,0.001122
4
+ 2011-2015,4434,4281,0.9337,0.6951,0.7014,0.001563
5
+ 2016-2019,7196,5390,0.9586,0.6869,0.6749,0.001924
6
+ 2020-2025,76167,12660,0.9366,0.6813,0.7233,0.00146
models/v2/discovery4_shap.csv ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ feature,mean_shap
2
+ high_risk_subject,2.858722763233071
3
+ text_length,0.5538123455747093
4
+ vocab_diversity,0.3800564257190622
5
+ word_count,0.1738597881128238
6
+ ai_phrase_density,0.16499230270350282
7
+ title_has_numbers,0.12505067345809898
8
+ predatory_journal,0.11936489742095206
9
+ integrity_index,0.10876963508813016
10
+ avg_sentence_len,0.1043366487077405
11
+ sentence_uniformity,0.09740337461578967
12
+ n_sentences,0.09257680900449686
13
+ title_length,0.09249490741438138
14
+ number_density,0.0895943367730984
15
+ burstiness,0.08317539684640174
16
+ terminal_zero_bias,0.06384226611554118
17
+ sig_word_count,0.04500065075451349
18
+ ai_phrase_count,0.04102301401985872
19
+ round_number_ratio,0.03929321748706706
20
+ n_numbers,0.03055996284794153
21
+ benford_deviation,0.027022442182091946
22
+ method_good_count,0.019135818542551208
23
+ self_cite_signals,0.0188344014127897
24
+ min_sample,0.01513005760091671
25
+ repro_positive,0.012220318576378855
26
+ max_sample,0.009751336086874712
27
+ high_risk_combo,0.005537203968505615
28
+ n_pvalues,0.005069728722888108
29
+ title_hype_count,0.0031475488514943624
30
+ citation_density,0.0024374710951270413
31
+ methodology_score,0.0019132915818899099
32
+ n_samples_found,0.001489920273936035
33
+ tiny_sample,0.0012696967865602426
34
+ n_citations,0.00120387309414186
35
+ method_bad_count,0.00033601677382852303
36
+ pval_cluster,1.8529207468211032e-05
37
+ has_p05,0.0
38
+ repro_negative,0.0
models/v2/discovery4_shap_direction.csv ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ,0
2
+ word_count,-0.013793937955026803
3
+ text_length,0.044525863865378205
4
+ title_length,0.029859732846550156
5
+ vocab_diversity,0.15262874423175413
6
+ n_pvalues,-0.003249973917962664
7
+ has_p05,0.0
8
+ pval_cluster,1.4719028696642418e-05
9
+ sig_word_count,-0.0006557237067251262
10
+ n_samples_found,-0.0006756252570232992
11
+ min_sample,0.0029281897969432153
12
+ max_sample,-0.002406571163844884
13
+ tiny_sample,-0.0001061529177496385
14
+ number_density,0.010059646960512456
15
+ n_numbers,-0.0035254643452687588
16
+ round_number_ratio,0.007695194168571795
17
+ terminal_zero_bias,-0.00028542627980636214
18
+ ai_phrase_count,0.0026368145487907163
19
+ ai_phrase_density,0.019936632287138985
20
+ sentence_uniformity,0.018367495926420997
21
+ avg_sentence_len,0.027829266526198063
22
+ burstiness,0.008548630236137555
23
+ n_sentences,0.03349845913127397
24
+ method_good_count,0.0035862170099135613
25
+ method_bad_count,-0.00031067201375495525
26
+ methodology_score,7.489977752458956e-06
27
+ n_citations,0.0003116165463023591
28
+ citation_density,-6.214627000642982e-05
29
+ self_cite_signals,-0.003208956852757935
30
+ repro_positive,0.003739803717666366
31
+ repro_negative,0.0
32
+ title_hype_count,-0.00010904902857386402
33
+ title_has_numbers,-0.021923789539280855
34
+ predatory_journal,0.04191622597320967
35
+ high_risk_subject,4.645669316112703
36
+ benford_deviation,0.012912697423546324
37
+ high_risk_combo,0.0010671632491703273
38
+ integrity_index,0.01918071315317233
models/v2/ensemble_config_v2.json ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "2.0.0",
3
+ "dataset": "SciPeerBench v2.0 -- 91,779 papers",
4
+ "features": [
5
+ "word_count",
6
+ "text_length",
7
+ "title_length",
8
+ "vocab_diversity",
9
+ "n_pvalues",
10
+ "has_p05",
11
+ "pval_cluster",
12
+ "sig_word_count",
13
+ "n_samples_found",
14
+ "min_sample",
15
+ "max_sample",
16
+ "tiny_sample",
17
+ "number_density",
18
+ "n_numbers",
19
+ "round_number_ratio",
20
+ "terminal_zero_bias",
21
+ "ai_phrase_count",
22
+ "ai_phrase_density",
23
+ "sentence_uniformity",
24
+ "avg_sentence_len",
25
+ "burstiness",
26
+ "n_sentences",
27
+ "method_good_count",
28
+ "method_bad_count",
29
+ "methodology_score",
30
+ "n_citations",
31
+ "citation_density",
32
+ "self_cite_signals",
33
+ "repro_positive",
34
+ "repro_negative",
35
+ "title_hype_count",
36
+ "title_has_numbers",
37
+ "predatory_journal",
38
+ "high_risk_subject",
39
+ "benford_deviation",
40
+ "high_risk_combo",
41
+ "integrity_index"
42
+ ],
43
+ "n_features": 37,
44
+ "weights": {
45
+ "lgbm": 0.4,
46
+ "xgb": 0.35,
47
+ "rf": 0.15,
48
+ "lr": 0.1
49
+ },
50
+ "threshold": 0.59,
51
+ "test_auc": 0.895,
52
+ "test_pr_auc": 0.8642,
53
+ "test_f1": 0.7766,
54
+ "test_recall": 0.6626,
55
+ "train_size": 64245,
56
+ "test_size": 13767,
57
+ "split": "stratified 70/15/15"
58
+ }
models/v2/feature_importance_v2.csv ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ feature,importance
2
+ vocab_diversity,8597
3
+ text_length,8052
4
+ integrity_index,6387
5
+ word_count,6089
6
+ avg_sentence_len,5774
7
+ title_length,5279
8
+ number_density,4779
9
+ sentence_uniformity,4256
10
+ burstiness,3643
11
+ n_sentences,2381
12
+ ai_phrase_density,2262
13
+ round_number_ratio,1970
14
+ terminal_zero_bias,1796
15
+ n_numbers,1771
16
+ benford_deviation,1244
17
+ sig_word_count,1095
18
+ title_has_numbers,1066
19
+ predatory_journal,513
20
+ min_sample,469
21
+ method_good_count,417
22
+ ai_phrase_count,292
23
+ high_risk_subject,278
24
+ n_pvalues,220
25
+ max_sample,212
26
+ high_risk_combo,159
27
+ citation_density,148
28
+ self_cite_signals,100
29
+ repro_positive,96
30
+ n_samples_found,83
31
+ title_hype_count,82
32
+ methodology_score,61
33
+ method_bad_count,46
34
+ n_citations,37
35
+ tiny_sample,26
36
+ pval_cluster,1
37
+ has_p05,0
38
+ repro_negative,0
models/v2/lgbm_v2.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:754866ad54a1b33b12276155b397c5787c7053fece9ab8b7e6f706936ad01577
3
+ size 381791
models/v2/lr_v2.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:681b48cf3a513c3b75b52deae029930cba2b89f8f6ee49121c1625a9732fa001
3
+ size 2286
models/v2/xgb_v2.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9e0d9fda9b15308c90747371adc0b40bfd932468936fd9e522634cb66ae9c1f9
3
+ size 1628049
railway.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://railway.app/railway.schema.json",
3
+ "build": {
4
+ "builder": "NIXPACKS"
5
+ },
6
+ "deploy": {
7
+ "startCommand": "uvicorn main:app --host 0.0.0.0 --port $PORT",
8
+ "healthcheckPath": "/",
9
+ "restartPolicyType": "ON_FAILURE"
10
+ }
11
+ }
requirements.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn==0.30.0
3
+ pymupdf==1.24.0
4
+ pytest==8.0.0
5
+ httpx==0.27.0
6
+ python-dotenv==1.0.0
7
+ pydantic==2.7.0
8
+ Pillow==10.3.0
9
+ imagehash==4.3.2
10
+ numpy==1.26.4
11
+ scipy==1.13.0
12
+ PyWavelets==1.6.0
13
+ python-multipart==0.0.9
14
+ requests==2.32.3
15
+
16
+ lightgbm==4.3.0
17
+ xgboost==2.0.3
18
+ scikit-learn==1.4.2
19
+ joblib==1.4.2
src/scipeerai/__init__.py ADDED
File without changes
src/scipeerai/api/__init__.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from src.scipeerai.api.routes import router
4
+
5
+
6
+ def create_app() -> FastAPI:
7
+ app = FastAPI(
8
+ title="SciPeerAI",
9
+ description=(
10
+ "Automated Scientific Integrity & Peer Review Analysis. "
11
+ "Upload paper text and get back a structured integrity report."
12
+ ),
13
+ version="0.1.0",
14
+ )
15
+
16
+ app.add_middleware(
17
+ CORSMiddleware,
18
+ allow_origins=["*"],
19
+ allow_credentials=True,
20
+ allow_methods=["*"],
21
+ allow_headers=["*"],
22
+ )
23
+
24
+ app.include_router(router)
25
+
26
+ @app.get("/", tags=["System"])
27
+ def health_check():
28
+ return {
29
+ "status": "online",
30
+ "system": "SciPeerAI",
31
+ "version": "0.1.0",
32
+ "message": "Scientific Integrity Engine is running"
33
+ }
34
+
35
+ return app
src/scipeerai/api/routes.py ADDED
The diff for this file is too large to render. See raw diff
 
src/scipeerai/core/__init__.py ADDED
File without changes
src/scipeerai/core/pdf_parser.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PDF Parser β€” Entry point for every paper analysis.
3
+
4
+ Every analysis we do depends on clean text extraction.
5
+ If this is wrong, everything downstream is wrong.
6
+
7
+ SciPeerAI v2.3.1 β€” Built by Sameer Nadeem
8
+ Table extraction + DOI harvesting added.
9
+ """
10
+
11
+ import re
12
+ import hashlib
13
+ import fitz
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+
18
+ MAX_FILE_SIZE_MB = 50
19
+ MAX_PAGES = 300
20
+ ALLOWED_MIME_HEADER = b"%PDF"
21
+
22
+
23
+ @dataclass
24
+ class ParsedPaper:
25
+ title: str
26
+ full_text: str
27
+ sections: dict
28
+ page_count: int
29
+ has_figures: bool
30
+ figure_count: int
31
+ metadata: dict
32
+ tables_text: str = ""
33
+ dois_found: list = None
34
+
35
+ def __post_init__(self):
36
+ if self.dois_found is None:
37
+ self.dois_found = []
38
+
39
+
40
+ class PDFParser:
41
+ """
42
+ Handles PDF ingestion and structured text extraction.
43
+ v2.3.1 upgrades:
44
+ - Table extraction via PyMuPDF blocks
45
+ - Structured number extraction for GRIM/SPRITE/P-Curve
46
+ - DOI harvesting from full document
47
+ - Section-aware extraction
48
+ """
49
+
50
+ def __init__(self):
51
+ self._section_markers = [
52
+ "abstract", "introduction", "methods", "methodology",
53
+ "results", "discussion", "conclusion", "references",
54
+ "related work", "background", "experiments",
55
+ "materials and methods", "statistical analysis",
56
+ "data analysis", "findings", "participants",
57
+ ]
58
+ self._doi_pattern = re.compile(
59
+ r'10\.\d{4,9}/[^\s\],;"\'<>\|\{\}\\]+',
60
+ re.IGNORECASE
61
+ )
62
+
63
+ def parse(self, pdf_path: str) -> ParsedPaper:
64
+ pdf_path = Path(pdf_path)
65
+ if not pdf_path.exists():
66
+ raise FileNotFoundError(f"Paper not found: {pdf_path}")
67
+ if pdf_path.suffix.lower() != ".pdf":
68
+ raise ValueError(f"Expected PDF file, got: {pdf_path.suffix}")
69
+ raw_bytes = pdf_path.read_bytes()
70
+ return self.parse_bytes(raw_bytes, filename=pdf_path.name)
71
+
72
+ def parse_bytes(self, file_bytes: bytes, filename: str = "upload.pdf") -> ParsedPaper:
73
+ filename = self._sanitize_filename(filename)
74
+ self._validate_bytes(file_bytes, filename)
75
+
76
+ doc = fitz.open(stream=file_bytes, filetype="pdf")
77
+
78
+ if len(doc) > MAX_PAGES:
79
+ doc.close()
80
+ raise ValueError(
81
+ f"Paper has {len(doc)} pages. Maximum allowed is {MAX_PAGES}."
82
+ )
83
+
84
+ full_text = self._extract_text(doc)
85
+ tables_text = self._extract_tables(doc)
86
+ combined = full_text + "\n\n" + tables_text
87
+ sections = self._split_into_sections(combined)
88
+ figure_count = self._count_figures(doc)
89
+ title = self._extract_title(doc, full_text)
90
+ page_count = len(doc)
91
+ dois_found = self._extract_dois(combined)
92
+
93
+ doc.close()
94
+
95
+ return ParsedPaper(
96
+ title = title,
97
+ full_text = combined,
98
+ sections = sections,
99
+ page_count = page_count,
100
+ has_figures = figure_count > 0,
101
+ figure_count = figure_count,
102
+ dois_found = dois_found,
103
+ tables_text = tables_text,
104
+ metadata = {
105
+ "filename": filename,
106
+ "file_size_kb": round(len(file_bytes) / 1024, 2),
107
+ "sha256": hashlib.sha256(file_bytes).hexdigest(),
108
+ },
109
+ )
110
+
111
+ def _validate_bytes(self, file_bytes: bytes, filename: str) -> None:
112
+ if len(file_bytes) == 0:
113
+ raise ValueError("Uploaded file is empty.")
114
+ max_bytes = MAX_FILE_SIZE_MB * 1024 * 1024
115
+ if len(file_bytes) > max_bytes:
116
+ size_mb = round(len(file_bytes) / 1024 / 1024, 1)
117
+ raise ValueError(
118
+ f"File too large: {size_mb} MB. Maximum: {MAX_FILE_SIZE_MB} MB."
119
+ )
120
+ if not file_bytes.startswith(ALLOWED_MIME_HEADER):
121
+ raise ValueError("Invalid file. Only real PDF files are accepted.")
122
+
123
+ @staticmethod
124
+ def _sanitize_filename(filename: str) -> str:
125
+ name = Path(filename).name
126
+ if not name.lower().endswith(".pdf"):
127
+ raise ValueError(f"Expected a PDF filename, got: {filename}")
128
+ return name
129
+
130
+ def _extract_text(self, doc: fitz.Document) -> str:
131
+ pages = []
132
+ for page in doc:
133
+ pages.append(page.get_text("text"))
134
+ return "\n".join(pages)
135
+
136
+ def _extract_tables(self, doc: fitz.Document) -> str:
137
+ """
138
+ Extract structured table content from PDF using block-level parsing.
139
+ This is critical for GRIM/SPRITE/P-Curve modules to find
140
+ means, SDs, sample sizes, and p-values that live inside tables.
141
+ """
142
+ table_parts = []
143
+
144
+ for page_num, page in enumerate(doc):
145
+ blocks = page.get_text("blocks")
146
+
147
+ rows = []
148
+ for block in blocks:
149
+ if block[6] != 0:
150
+ continue
151
+ text = block[4].strip()
152
+ if not text:
153
+ continue
154
+ rows.append((block[1], text))
155
+
156
+ rows.sort(key=lambda x: x[0])
157
+
158
+ for _, text in rows:
159
+ if any(char.isdigit() for char in text):
160
+ clean = " ".join(text.split())
161
+ table_parts.append(clean)
162
+
163
+ if not table_parts:
164
+ return ""
165
+
166
+ return "\nTABLE_DATA:\n" + "\n".join(table_parts)
167
+
168
+ def _split_into_sections(self, text: str) -> dict:
169
+ sections = {}
170
+ text_lower = text.lower()
171
+
172
+ for i, marker in enumerate(self._section_markers):
173
+ start_idx = text_lower.find(marker)
174
+ if start_idx == -1:
175
+ continue
176
+
177
+ end_idx = len(text)
178
+ for next_marker in self._section_markers[i + 1:]:
179
+ next_idx = text_lower.find(next_marker, start_idx + 1)
180
+ if next_idx != -1:
181
+ end_idx = next_idx
182
+ break
183
+
184
+ sections[marker] = text[start_idx:end_idx].strip()
185
+
186
+ return sections
187
+
188
+ def _count_figures(self, doc: fitz.Document) -> int:
189
+ total = 0
190
+ for page in doc:
191
+ total += len(page.get_images())
192
+ return total
193
+
194
+ def _extract_title(self, doc: fitz.Document, full_text: str) -> str:
195
+ meta = doc.metadata
196
+ if meta and meta.get("title"):
197
+ return meta["title"].strip()
198
+ for line in full_text.split("\n"):
199
+ line = line.strip()
200
+ if len(line) > 10:
201
+ return line
202
+ return "Unknown Title"
203
+
204
+ def _extract_dois(self, text: str) -> list:
205
+ """
206
+ Extract all DOIs from full document text including tables and headers.
207
+ Uses broad pattern to catch DOIs in any format.
208
+ """
209
+ dois = []
210
+ for m in self._doi_pattern.finditer(text):
211
+ doi = m.group(0).rstrip('.,;)')
212
+ doi_clean = doi.lower()
213
+ if doi_clean not in dois:
214
+ dois.append(doi_clean)
215
+ return dois[:50]
src/scipeerai/modules/__init__.py ADDED
File without changes
src/scipeerai/modules/ai_human_spectrum.py ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AI-Human Collaboration Spectrum
2
+ # --------------------------------
3
+ # The question is no longer "was this written by AI?"
4
+ # That binary is already obsolete.
5
+ #
6
+ # The real question is: how much of this paper is human,
7
+ # how much is AI, which sections crossed the line,
8
+ # and β€” critically β€” which AI system left its fingerprints?
9
+ #
10
+ # GPT-4 writes differently from Claude.
11
+ # Claude writes differently from Gemini.
12
+ # Each system has signature phrase patterns, sentence
13
+ # rhythm preferences, and structural habits that persist
14
+ # even when users try to disguise them.
15
+ #
16
+ # This module does not just score AI probability.
17
+ # It maps the collaboration: section by section,
18
+ # model by model, returning a spectrum rather than
19
+ # a verdict. A paper that is 30% AI-assisted in the
20
+ # discussion section is a different integrity question
21
+ # than one that is 90% AI in the methods section.
22
+ #
23
+ # The spectrum is the signal.
24
+
25
+ import re
26
+ import math
27
+ from dataclasses import dataclass, field
28
+ from collections import Counter
29
+
30
+
31
+ # ── model signature phrases ────────────────────────────────────────────────────
32
+ # These are characteristic patterns each model uses at elevated frequency.
33
+ # Compiled from large-scale analysis of known model outputs.
34
+
35
+ GPT4_SIGNATURES = [
36
+ "it is worth noting", "it is important to note",
37
+ "in the context of", "with respect to",
38
+ "it should be noted that", "as mentioned earlier",
39
+ "in this regard", "to this end",
40
+ "plays a crucial role", "serves as a foundation",
41
+ "a comprehensive understanding", "shed light on",
42
+ "this is particularly important", "in recent years",
43
+ "has been widely studied", "it is well established",
44
+ ]
45
+
46
+ CLAUDE_SIGNATURES = [
47
+ "let me", "i should note", "it's worth",
48
+ "to be clear", "more specifically",
49
+ "at the same time", "in other words",
50
+ "this suggests that", "one important",
51
+ "the key insight", "fundamentally",
52
+ "carefully consider", "nuanced understanding",
53
+ "there are several", "it's important to",
54
+ ]
55
+
56
+ GEMINI_SIGNATURES = [
57
+ "based on the above", "in summary",
58
+ "to summarize", "the following",
59
+ "as shown in", "it can be seen",
60
+ "the results show", "figure shows",
61
+ "table shows", "as indicated",
62
+ "considering the", "taking into account",
63
+ "in light of", "with this in mind",
64
+ "building upon", "drawing from",
65
+ ]
66
+
67
+ # universal AI writing patterns β€” present across all models
68
+ UNIVERSAL_AI_PATTERNS = [
69
+ "delve into", "it is crucial", "it is essential",
70
+ "comprehensive overview", "multifaceted",
71
+ "in conclusion", "in summary", "to summarize",
72
+ "furthermore", "moreover", "additionally",
73
+ "it is noteworthy", "it is evident",
74
+ "underscore the importance", "highlight the need",
75
+ "pave the way", "open new avenues",
76
+ "robust framework", "holistic approach",
77
+ "seamlessly integrate", "leverage the power",
78
+ "transformative potential", "groundbreaking",
79
+ "paradigm shift", "cutting-edge",
80
+ ]
81
+
82
+ # human academic writing patterns β€” signal genuine authorship
83
+ HUMAN_ACADEMIC_PATTERNS = [
84
+ "we observed", "we note that", "we found",
85
+ "surprisingly", "unexpectedly", "contrary to",
86
+ "we were unable to", "failed to",
87
+ "this is consistent with", "in contrast to our expectation",
88
+ "personal communication", "unpublished data",
89
+ "in our experience", "we speculate",
90
+ "one limitation", "a key limitation",
91
+ "we cannot rule out", "remains unclear",
92
+ ]
93
+
94
+ SECTION_HEADERS = [
95
+ "abstract", "introduction", "background",
96
+ "methods", "methodology", "materials",
97
+ "results", "findings", "discussion",
98
+ "conclusion", "references",
99
+ ]
100
+
101
+
102
+ # ── data structures ────────────────────────────────────────────────────────────
103
+
104
+ @dataclass
105
+ class SectionSpectrum:
106
+ section: str
107
+ human_score: float
108
+ ai_score: float
109
+ dominant_model: str
110
+ confidence: float
111
+
112
+
113
+ @dataclass
114
+ class SpectrumFlag:
115
+ flag_type: str
116
+ severity: str
117
+ description: str
118
+ evidence: str
119
+ suggestion: str
120
+
121
+
122
+ @dataclass
123
+ class AIHumanSpectrumResult:
124
+ overall_human_ratio: float
125
+ overall_ai_ratio: float
126
+ dominant_ai_model: str
127
+ model_confidence: float
128
+ section_breakdown: list
129
+ ai_sections: list
130
+ human_sections: list
131
+ universal_ai_count: int
132
+ gpt4_signal_count: int
133
+ claude_signal_count: int
134
+ gemini_signal_count: int
135
+ human_signal_count: int
136
+ spectrum_score: float
137
+ risk_level: str
138
+ summary: str
139
+ flags: list
140
+ flags_count: int
141
+
142
+
143
+ # ── main class ───────────────────────────────────────────────��────────────────
144
+
145
+ class AIHumanSpectrumAnalyzer:
146
+ """
147
+ Maps the AI-human collaboration spectrum of a paper.
148
+
149
+ Unlike binary AI detectors, this module produces a gradient:
150
+ - Overall human vs AI ratio
151
+ - Section-by-section breakdown
152
+ - Which AI model's signatures dominate
153
+ - Confidence level of model attribution
154
+
155
+ The output answers: not just "is this AI?" but
156
+ "where, how much, and which system?"
157
+ """
158
+
159
+ def analyze(self, text: str) -> AIHumanSpectrumResult:
160
+ if not text or len(text.strip()) < 50:
161
+ return self._empty_result()
162
+
163
+ flags = []
164
+ sections = self._split_sections(text)
165
+ section_spectra = [
166
+ self._analyze_section(name, content)
167
+ for name, content in sections.items()
168
+ ]
169
+
170
+ universal_ai = self._count_patterns(text, UNIVERSAL_AI_PATTERNS)
171
+ gpt4_count = self._count_patterns(text, GPT4_SIGNATURES)
172
+ claude_count = self._count_patterns(text, CLAUDE_SIGNATURES)
173
+ gemini_count = self._count_patterns(text, GEMINI_SIGNATURES)
174
+ human_count = self._count_patterns(text, HUMAN_ACADEMIC_PATTERNS)
175
+
176
+ dominant_model, model_conf = self._identify_dominant_model(
177
+ gpt4_count, claude_count, gemini_count, universal_ai
178
+ )
179
+
180
+ overall_ai = self._compute_overall_ai_ratio(
181
+ universal_ai, gpt4_count, claude_count,
182
+ gemini_count, human_count, text
183
+ )
184
+ overall_human = round(1.0 - overall_ai, 3)
185
+
186
+ ai_sections = [
187
+ s.section for s in section_spectra if s.ai_score >= 0.55
188
+ ]
189
+ human_sections = [
190
+ s.section for s in section_spectra if s.human_score >= 0.60
191
+ ]
192
+
193
+ spectrum_score = overall_ai
194
+ risk_level = self._get_risk_level(spectrum_score)
195
+
196
+ self._generate_flags(
197
+ overall_ai, ai_sections, dominant_model,
198
+ model_conf, universal_ai, flags
199
+ )
200
+
201
+ return AIHumanSpectrumResult(
202
+ overall_human_ratio = overall_human,
203
+ overall_ai_ratio = round(overall_ai, 3),
204
+ dominant_ai_model = dominant_model,
205
+ model_confidence = round(model_conf, 3),
206
+ section_breakdown = section_spectra,
207
+ ai_sections = ai_sections,
208
+ human_sections = human_sections,
209
+ universal_ai_count = universal_ai,
210
+ gpt4_signal_count = gpt4_count,
211
+ claude_signal_count = claude_count,
212
+ gemini_signal_count = gemini_count,
213
+ human_signal_count = human_count,
214
+ spectrum_score = round(spectrum_score, 3),
215
+ risk_level = risk_level,
216
+ summary = self._write_summary(
217
+ overall_ai, dominant_model, model_conf,
218
+ ai_sections, flags, risk_level
219
+ ),
220
+ flags = flags,
221
+ flags_count = len(flags),
222
+ )
223
+
224
+ # ── section analysis ───────────────────────────────────────────────────────
225
+
226
+ def _split_sections(self, text: str) -> dict:
227
+ """Split paper into named sections for per-section analysis."""
228
+ text_lo = text.lower()
229
+ positions = []
230
+
231
+ for header in SECTION_HEADERS:
232
+ idx = text_lo.find(header)
233
+ if idx == -1:
234
+ continue
235
+ line_start = text.rfind("\n", 0, idx) + 1
236
+ line_end = text.find("\n", idx)
237
+ if line_end == -1:
238
+ line_end = len(text)
239
+ line = text[line_start:line_end].strip()
240
+ if len(line) <= 40:
241
+ positions.append((idx, header))
242
+
243
+ if not positions:
244
+ return {"full_text": text}
245
+
246
+ positions.sort(key=lambda x: x[0])
247
+ deduped = [positions[0]]
248
+ for pos in positions[1:]:
249
+ if pos[0] - deduped[-1][0] > 30:
250
+ deduped.append(pos)
251
+
252
+ sections = {}
253
+ for i, (start, name) in enumerate(deduped):
254
+ end = deduped[i + 1][0] if i + 1 < len(deduped) else len(text)
255
+ sections[name] = text[start:end]
256
+
257
+ return sections
258
+
259
+ def _analyze_section(self, name: str, text: str) -> SectionSpectrum:
260
+ """Compute AI vs human score for a single section."""
261
+ if len(text.strip()) < 40:
262
+ return SectionSpectrum(
263
+ section = name,
264
+ human_score = 0.5,
265
+ ai_score = 0.5,
266
+ dominant_model = "unknown",
267
+ confidence = 0.0,
268
+ )
269
+
270
+ ai_hits = self._count_patterns(text, UNIVERSAL_AI_PATTERNS)
271
+ human_hits = self._count_patterns(text, HUMAN_ACADEMIC_PATTERNS)
272
+ words = max(len(text.split()), 1)
273
+
274
+ ai_density = ai_hits / (words / 100)
275
+ human_density = human_hits / (words / 100)
276
+ total_density = ai_density + human_density
277
+
278
+ if total_density == 0:
279
+ ai_score = 0.3
280
+ human_score = 0.7
281
+ else:
282
+ ai_score = round(ai_density / total_density, 3)
283
+ human_score = round(human_density / total_density, 3)
284
+
285
+ gpt4 = self._count_patterns(text, GPT4_SIGNATURES)
286
+ claude = self._count_patterns(text, CLAUDE_SIGNATURES)
287
+ gemini = self._count_patterns(text, GEMINI_SIGNATURES)
288
+
289
+ model, conf = self._identify_dominant_model(gpt4, claude, gemini, ai_hits)
290
+
291
+ return SectionSpectrum(
292
+ section = name,
293
+ human_score = human_score,
294
+ ai_score = ai_score,
295
+ dominant_model = model,
296
+ confidence = round(conf, 3),
297
+ )
298
+
299
+ # ── model identification ───────────────────────────────────────────────────
300
+
301
+ def _identify_dominant_model(
302
+ self,
303
+ gpt4: int,
304
+ claude: int,
305
+ gemini: int,
306
+ universal: int,
307
+ ) -> tuple:
308
+ """
309
+ Identify which AI model's signatures dominate the text.
310
+ Returns (model_name, confidence_score).
311
+ Confidence is low when signals are weak or mixed.
312
+ """
313
+ total = gpt4 + claude + gemini
314
+ if total == 0:
315
+ return ("none", 0.0)
316
+
317
+ scores = {"GPT-4": gpt4, "Claude": claude, "Gemini": gemini}
318
+ dominant = max(scores, key=scores.get)
319
+ top_score = scores[dominant]
320
+ confidence = top_score / total
321
+
322
+ # boost confidence when universal AI patterns also present
323
+ if universal >= 3:
324
+ confidence = min(confidence * 1.2, 1.0)
325
+
326
+ # low confidence when signals are evenly distributed
327
+ if confidence < 0.45:
328
+ dominant = "mixed"
329
+
330
+ return (dominant, round(confidence, 3))
331
+
332
+ # ── ratio computation ──────────────────────────────────────────────────────
333
+
334
+ def _compute_overall_ai_ratio(
335
+ self,
336
+ universal: int,
337
+ gpt4: int,
338
+ claude: int,
339
+ gemini: int,
340
+ human: int,
341
+ text: str,
342
+ ) -> float:
343
+ """
344
+ Compute the overall probability that the paper is AI-generated.
345
+ Blends multiple signals: phrase patterns, sentence uniformity,
346
+ and vocabulary burstiness.
347
+ """
348
+ words = max(len(text.split()), 1)
349
+ ai_total = universal + gpt4 + claude + gemini
350
+
351
+ phrase_ratio = min(ai_total / (words / 50), 1.0)
352
+
353
+ burstiness = self._compute_burstiness(text)
354
+ # AI text has lower burstiness β€” more uniform sentence lengths
355
+ uniformity = 1.0 - min(burstiness, 1.0)
356
+
357
+ ttr = self._compute_ttr(text)
358
+ # AI text has slightly lower type-token ratio
359
+ ai_ttr_signal = max(0.0, 0.75 - ttr)
360
+
361
+ human_signal = min(human / max(words / 100, 1), 1.0)
362
+
363
+ raw = (
364
+ phrase_ratio * 0.45 +
365
+ uniformity * 0.25 +
366
+ ai_ttr_signal * 0.15 +
367
+ (1.0 - min(human_signal * 3, 1.0)) * 0.15
368
+ )
369
+
370
+ return round(min(max(raw, 0.0), 1.0), 3)
371
+
372
+ def _compute_burstiness(self, text: str) -> float:
373
+ """
374
+ Burstiness measures sentence length variance.
375
+ Human writing bursts β€” short then long then medium.
376
+ AI writing is smoother, more uniform.
377
+ """
378
+ sentences = re.split(r'[.!?]+', text)
379
+ lengths = [len(s.split()) for s in sentences if len(s.split()) > 2]
380
+
381
+ if len(lengths) < 5:
382
+ return 0.5
383
+
384
+ mean = sum(lengths) / len(lengths)
385
+ if mean == 0:
386
+ return 0.0
387
+
388
+ variance = sum((l - mean) ** 2 for l in lengths) / len(lengths)
389
+ cv = math.sqrt(variance) / mean
390
+
391
+ return round(min(cv, 1.0), 3)
392
+
393
+ def _compute_ttr(self, text: str) -> float:
394
+ """Type-token ratio on first 300 words β€” vocabulary diversity."""
395
+ words = re.findall(r'\b[a-z]{3,}\b', text.lower())[:300]
396
+ if len(words) < 20:
397
+ return 0.7
398
+ return round(len(set(words)) / len(words), 3)
399
+
400
+ # ── pattern counting ───────────────────────────────────────────────────────
401
+
402
+ def _count_patterns(self, text: str, patterns: list) -> int:
403
+ text_lo = text.lower()
404
+ return sum(1 for p in patterns if p in text_lo)
405
+
406
+ # ── flags ──────────────────────────────────────────────────────────────────
407
+
408
+ def _generate_flags(
409
+ self,
410
+ overall_ai: float,
411
+ ai_sections: list,
412
+ dominant_model: str,
413
+ model_conf: float,
414
+ universal_ai: int,
415
+ flags: list,
416
+ ) -> None:
417
+
418
+ if overall_ai >= 0.70:
419
+ flags.append(SpectrumFlag(
420
+ flag_type = "high_ai_generation_probability",
421
+ severity = "high",
422
+ description = (
423
+ f"Overall AI generation probability: "
424
+ f"{round(overall_ai * 100, 1)}%. "
425
+ f"The paper's writing patterns are strongly consistent "
426
+ f"with AI-generated text rather than human academic writing."
427
+ ),
428
+ evidence = (
429
+ f"AI ratio: {round(overall_ai * 100, 1)}%. "
430
+ f"Universal AI patterns detected: {universal_ai}. "
431
+ f"Dominant model signature: {dominant_model} "
432
+ f"(confidence: {round(model_conf * 100, 1)}%)."
433
+ ),
434
+ suggestion = (
435
+ "If AI tools were used in writing, disclose this per "
436
+ "journal policy. Substantial AI generation without "
437
+ "disclosure violates most journal ethics guidelines."
438
+ ),
439
+ ))
440
+ elif overall_ai >= 0.45:
441
+ flags.append(SpectrumFlag(
442
+ flag_type = "moderate_ai_assistance_detected",
443
+ severity = "medium",
444
+ description = (
445
+ f"Moderate AI writing assistance detected: "
446
+ f"{round(overall_ai * 100, 1)}% AI signal. "
447
+ f"This level suggests significant AI drafting or editing "
448
+ f"beyond light grammar assistance."
449
+ ),
450
+ evidence = (
451
+ f"AI probability: {round(overall_ai * 100, 1)}%. "
452
+ f"Sections with AI patterns: {ai_sections if ai_sections else 'distributed'}."
453
+ ),
454
+ suggestion = (
455
+ "Disclose AI tool usage per applicable journal policy."
456
+ ),
457
+ ))
458
+
459
+ if dominant_model not in ("none", "mixed", "unknown") and model_conf >= 0.55:
460
+ flags.append(SpectrumFlag(
461
+ flag_type = "model_signature_detected",
462
+ severity = "low",
463
+ description = (
464
+ f"Writing signatures consistent with {dominant_model} "
465
+ f"detected with {round(model_conf * 100, 1)}% confidence. "
466
+ f"Characteristic phrase patterns of this system appear "
467
+ f"at elevated frequency."
468
+ ),
469
+ evidence = (
470
+ f"Dominant model: {dominant_model}. "
471
+ f"Attribution confidence: {round(model_conf * 100, 1)}%."
472
+ ),
473
+ suggestion = (
474
+ f"If {dominant_model} was used, disclose this in the "
475
+ f"methods or acknowledgments section."
476
+ ),
477
+ ))
478
+
479
+ if len(ai_sections) >= 2:
480
+ flags.append(SpectrumFlag(
481
+ flag_type = "multiple_sections_ai_generated",
482
+ severity = "medium",
483
+ description = (
484
+ f"{len(ai_sections)} paper section(s) show elevated "
485
+ f"AI writing patterns: {ai_sections}. "
486
+ f"Concentrated AI generation in core sections "
487
+ f"raises disclosure concerns."
488
+ ),
489
+ evidence = f"AI-pattern sections: {ai_sections}.",
490
+ suggestion = (
491
+ "Review each flagged section for undisclosed AI generation. "
492
+ "Methods and Results sections require particular scrutiny."
493
+ ),
494
+ ))
495
+
496
+ # ── scoring ────────────────────────────────────────────────────────────────
497
+
498
+ def _get_risk_level(self, score: float) -> str:
499
+ if score >= 0.70: return "critical"
500
+ if score >= 0.45: return "high"
501
+ if score >= 0.25: return "medium"
502
+ return "low"
503
+
504
+ def _write_summary(
505
+ self,
506
+ overall_ai: float,
507
+ dominant_model: str,
508
+ model_conf: float,
509
+ ai_sections: list,
510
+ flags: list,
511
+ risk_level: str,
512
+ ) -> str:
513
+ human_pct = round((1.0 - overall_ai) * 100, 1)
514
+ ai_pct = round(overall_ai * 100, 1)
515
+
516
+ model_str = ""
517
+ if dominant_model not in ("none", "mixed", "unknown") and model_conf >= 0.45:
518
+ model_str = (
519
+ f" Dominant AI model signature: {dominant_model} "
520
+ f"({round(model_conf * 100, 1)}% confidence)."
521
+ )
522
+
523
+ section_str = ""
524
+ if ai_sections:
525
+ section_str = f" AI-dominant sections: {ai_sections}."
526
+
527
+ return (
528
+ f"AI-Human Spectrum Analysis: "
529
+ f"{human_pct}% human / {ai_pct}% AI estimated."
530
+ f"{model_str}{section_str} "
531
+ f"Risk level: {risk_level.upper()}."
532
+ )
533
+
534
+ def _empty_result(self) -> AIHumanSpectrumResult:
535
+ return AIHumanSpectrumResult(
536
+ overall_human_ratio = 1.0,
537
+ overall_ai_ratio = 0.0,
538
+ dominant_ai_model = "none",
539
+ model_confidence = 0.0,
540
+ section_breakdown = [],
541
+ ai_sections = [],
542
+ human_sections = [],
543
+ universal_ai_count = 0,
544
+ gpt4_signal_count = 0,
545
+ claude_signal_count = 0,
546
+ gemini_signal_count = 0,
547
+ human_signal_count = 0,
548
+ spectrum_score = 0.0,
549
+ risk_level = "low",
550
+ summary = (
551
+ "AI-Human Spectrum Analysis: Insufficient text for analysis. "
552
+ "Risk level: LOW."
553
+ ),
554
+ flags = [],
555
+ flags_count = 0,
556
+ )
src/scipeerai/modules/citation_analyzer.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Citation Integrity Analyzer
2
+ # ---------------------------
3
+ # Citations are the backbone of science.
4
+ # When they are manipulated β€” through self-citation
5
+ # abuse, retracted sources, or citation cartels β€”
6
+ # the entire knowledge chain gets corrupted.
7
+ #
8
+ # This module audits citation patterns in paper text
9
+ # and checks references against retraction databases.
10
+
11
+ import re
12
+ import json
13
+ import urllib.request
14
+ import urllib.error
15
+ from dataclasses import dataclass
16
+
17
+
18
+ # ── data structures ───────────────────────────────────────────
19
+
20
+ @dataclass
21
+ class CitationFlag:
22
+ flag_type: str
23
+ severity: str
24
+ description: str
25
+ evidence: str
26
+ suggestion: str
27
+
28
+
29
+ @dataclass
30
+ class CitationResult:
31
+ total_citations: int
32
+ self_citations: int
33
+ self_citation_ratio: float
34
+ unsupported_claims: int
35
+ flags: list
36
+ risk_score: float
37
+ risk_level: str
38
+ summary: str
39
+
40
+
41
+ # ── main class ────────────────────────────────────────────────
42
+
43
+ class CitationAnalyzer:
44
+ """
45
+ Two-layer citation analysis:
46
+
47
+ Layer 1 β€” Pattern analysis: self-citation ratio,
48
+ unsupported claims, citation density problems.
49
+
50
+ Layer 2 β€” External validation: checks author names
51
+ against Semantic Scholar for retraction signals.
52
+ Free API β€” no key required for basic usage.
53
+ """
54
+
55
+ # ratio above this is suspicious self-citation
56
+ SELF_CITE_THRESHOLD = 0.30
57
+
58
+ # claims that need citations but often don't have them
59
+ CLAIM_MARKERS = [
60
+ "studies show", "research shows", "evidence suggests",
61
+ "it is well known", "it has been shown", "it is established",
62
+ "previous work shows", "literature suggests",
63
+ "experts agree", "scientists believe"
64
+ ]
65
+
66
+ def __init__(self):
67
+ self._semantic_scholar_url = (
68
+ "https://api.semanticscholar.org/graph/v1/paper/search"
69
+ )
70
+
71
+ # ── public method ─────────────────────────────────────────
72
+
73
+ def analyze(self, text: str, author_name: str = "") -> CitationResult:
74
+ """
75
+ Full citation integrity analysis.
76
+
77
+ Args:
78
+ text: Full paper text
79
+ author_name: Primary author β€” used for self-citation detection
80
+ """
81
+ citations = self._extract_citations(text)
82
+ self_cites = self._count_self_citations(text, author_name)
83
+ unsupported = self._find_unsupported_claims(text)
84
+
85
+ total = len(citations)
86
+ self_ratio = (self_cites / total) if total > 0 else 0.0
87
+
88
+ flags = []
89
+ flags.extend(self._check_self_citation_ratio(
90
+ self_cites, total, self_ratio
91
+ ))
92
+ flags.extend(self._check_unsupported_claims(unsupported))
93
+ flags.extend(self._check_citation_density(text, total))
94
+ flags.extend(self._check_citation_patterns(text, citations))
95
+
96
+ # try live retraction check β€” graceful fallback
97
+ if author_name:
98
+ retraction_flags = self._check_retraction_signals(
99
+ citations, author_name
100
+ )
101
+ flags.extend(retraction_flags)
102
+
103
+ risk_score = self._calculate_risk(flags)
104
+ risk_level = self._get_risk_level(risk_score)
105
+
106
+ return CitationResult(
107
+ total_citations=total,
108
+ self_citations=self_cites,
109
+ self_citation_ratio=round(self_ratio, 3),
110
+ unsupported_claims=len(unsupported),
111
+ flags=flags,
112
+ risk_score=round(risk_score, 3),
113
+ risk_level=risk_level,
114
+ summary=self._write_summary(flags, risk_level, total),
115
+ )
116
+
117
+ # ── extraction helpers ────────────────────────────────────
118
+
119
+ # PATCH ONLY β€” replace _extract_citations method in citation_analyzer.py
120
+
121
+ def _extract_citations(self, text: str) -> list:
122
+ """
123
+ v2.3.2 β€” Extract citation markers from text.
124
+ Handles: [1], [1,2], [1-3], (Smith, 2020), (Smith et al., 2019),
125
+ and plain format: Smith 2020, Jones et al. 2019 (no brackets).
126
+ """
127
+ patterns = [
128
+ r'\[\d+(?:,\s*\d+)*\]', # [1] or [1,2,3]
129
+ r'\[\d+\-\d+\]', # [1-3]
130
+ r'\([A-Z][a-z]+(?:\s+et\s+al\.?)?,?\s+\d{4}\)', # (Smith, 2020)
131
+ r'\([A-Z][a-z]+\s+&\s+[A-Z][a-z]+,?\s+\d{4}\)', # (Smith & Jones, 2020)
132
+ r'\b[A-Z][a-z]+(?:\s+et\s+al\.?)?\s+\d{4}\b', # Smith 2020 (no brackets)
133
+ ]
134
+ citations = []
135
+ for pattern in patterns:
136
+ found = re.findall(pattern, text)
137
+ citations.extend(found)
138
+
139
+ # deduplicate while preserving order
140
+ seen = set()
141
+ unique = []
142
+ for c in citations:
143
+ c_norm = re.sub(r'\s+', ' ', c.strip().lower())
144
+ if c_norm not in seen:
145
+ seen.add(c_norm)
146
+ unique.append(c)
147
+ return unique
148
+
149
+ def _count_self_citations(self, text: str, author_name: str) -> int:
150
+ """
151
+ Count how many times the author's own name appears
152
+ in citation context. Checks both surname variants.
153
+ """
154
+ if not author_name:
155
+ return 0
156
+
157
+ # extract surname β€” "Sameer Nadeem" β†’ "Nadeem"
158
+ parts = author_name.strip().split()
159
+ surname = parts[-1] if parts else author_name
160
+
161
+ # look for surname near citation patterns
162
+ citation_context = re.findall(
163
+ rf'{re.escape(surname)}[,\s]{{0,10}}(?:\d{{4}}|et al)',
164
+ text,
165
+ re.IGNORECASE
166
+ )
167
+ return len(citation_context)
168
+
169
+ def _find_unsupported_claims(self, text: str) -> list:
170
+ """
171
+ Find sentences that make broad claims without
172
+ a citation immediately following.
173
+ "Studies show that X" with no [1] or (Author, year) nearby.
174
+ """
175
+ unsupported = []
176
+ sentences = re.split(r'[.!?]', text)
177
+
178
+ for sentence in sentences:
179
+ sentence = sentence.strip()
180
+ if len(sentence) < 15:
181
+ continue
182
+
183
+ s_lower = sentence.lower()
184
+ has_claim_marker = any(
185
+ marker in s_lower for marker in self.CLAIM_MARKERS
186
+ )
187
+ has_citation = bool(re.search(
188
+ r'\[\d+\]|\([A-Z][a-z]+.*?\d{4}\)', sentence
189
+ ))
190
+
191
+ if has_claim_marker and not has_citation:
192
+ unsupported.append(sentence)
193
+
194
+ return unsupported[:5] # cap at 5 for report clarity
195
+
196
+ # ── flag checks ───────────────────────────────────────────
197
+
198
+ def _check_self_citation_ratio(
199
+ self, self_cites: int, total: int, ratio: float
200
+ ) -> list:
201
+ """
202
+ High self-citation ratio inflates the author's
203
+ citation metrics without adding scientific value.
204
+ """
205
+ flags = []
206
+
207
+ if total < 5:
208
+ return flags # too few citations to judge pattern
209
+
210
+ if ratio >= 0.5:
211
+ flags.append(CitationFlag(
212
+ flag_type="excessive_self_citation",
213
+ severity="high",
214
+ description=(
215
+ f"{self_cites} out of {total} citations "
216
+ f"({round(ratio*100)}%) appear to be self-citations. "
217
+ f"Threshold: {round(self.SELF_CITE_THRESHOLD*100)}%."
218
+ ),
219
+ evidence=f"Self-citation ratio: {round(ratio, 3)}",
220
+ suggestion=(
221
+ "Review whether all self-citations are necessary. "
222
+ "Journals typically flag ratios above 20-30%."
223
+ ),
224
+ ))
225
+ elif ratio >= self.SELF_CITE_THRESHOLD:
226
+ flags.append(CitationFlag(
227
+ flag_type="high_self_citation_ratio",
228
+ severity="medium",
229
+ description=(
230
+ f"Self-citation ratio of {round(ratio*100)}% "
231
+ f"is above the recommended threshold."
232
+ ),
233
+ evidence=f"Self-citation ratio: {round(ratio, 3)}",
234
+ suggestion=(
235
+ "Consider whether additional independent sources "
236
+ "could support the same claims."
237
+ ),
238
+ ))
239
+
240
+ return flags
241
+
242
+ def _check_unsupported_claims(self, unsupported: list) -> list:
243
+ """Flag broad claims that lack any citation support."""
244
+ flags = []
245
+
246
+ if len(unsupported) >= 3:
247
+ flags.append(CitationFlag(
248
+ flag_type="unsupported_broad_claims",
249
+ severity="high",
250
+ description=(
251
+ f"{len(unsupported)} broad claim(s) found without "
252
+ f"supporting citations. These cannot be independently verified."
253
+ ),
254
+ evidence=" | ".join(unsupported[:2]),
255
+ suggestion=(
256
+ "Each claim beginning with 'studies show' or "
257
+ "'it is well known' must be backed by specific citations."
258
+ ),
259
+ ))
260
+ elif len(unsupported) >= 1:
261
+ flags.append(CitationFlag(
262
+ flag_type="unsupported_claims",
263
+ severity="medium",
264
+ description=(
265
+ f"{len(unsupported)} claim(s) make broad assertions "
266
+ f"without citation support."
267
+ ),
268
+ evidence=unsupported[0] if unsupported else "",
269
+ suggestion="Add specific citations for each broad claim.",
270
+ ))
271
+
272
+ return flags
273
+
274
+ def _check_citation_density(self, text: str, total: int) -> list:
275
+ """
276
+ Very few citations in a long paper = claims without backing.
277
+ Very many in a short paper = padding.
278
+ """
279
+ flags = []
280
+ words = len(text.split())
281
+ # rough pages estimate
282
+ pages = max(1, words // 250)
283
+ density = total / pages
284
+
285
+ if pages >= 5 and density < 1.5:
286
+ flags.append(CitationFlag(
287
+ flag_type="low_citation_density",
288
+ severity="medium",
289
+ description=(
290
+ f"Only {total} citations across approximately "
291
+ f"{pages} pages (density: {round(density, 1)}/page). "
292
+ f"Well-supported papers typically cite 3-5 sources per page."
293
+ ),
294
+ evidence=f"{total} total citations, ~{pages} pages",
295
+ suggestion=(
296
+ "Review whether all major claims have adequate "
297
+ "citation support from independent sources."
298
+ ),
299
+ ))
300
+
301
+ return flags
302
+
303
+ def _check_citation_patterns(self, text: str, citations: list) -> list:
304
+ """
305
+ Detect suspicious citation clustering β€”
306
+ all citations in one section, none in others.
307
+ Also detects 'et al.' overuse which hides
308
+ the actual authors being cited.
309
+ """
310
+ flags = []
311
+
312
+ # et al. overuse β€” hides who is actually being cited
313
+ et_al_count = len(re.findall(r'et al\.?', text, re.IGNORECASE))
314
+ if citations and et_al_count > 0:
315
+ et_al_ratio = et_al_count / max(len(citations), 1)
316
+ if et_al_ratio > 0.7 and len(citations) > 5:
317
+ flags.append(CitationFlag(
318
+ flag_type="et_al_overuse",
319
+ severity="low",
320
+ description=(
321
+ f"{et_al_count} out of {len(citations)} citations "
322
+ f"use 'et al.' ({round(et_al_ratio*100)}%). "
323
+ f"This obscures the actual authorship of cited works."
324
+ ),
325
+ evidence=f"et al. ratio: {round(et_al_ratio, 2)}",
326
+ suggestion=(
327
+ "For papers with 3 or fewer authors, "
328
+ "list all names. Reserve et al. for 4+ authors."
329
+ ),
330
+ ))
331
+
332
+ return flags
333
+
334
+ def _check_retraction_signals(
335
+ self, citations: list, author_name: str
336
+ ) -> list:
337
+ """
338
+ Query Semantic Scholar for the author's papers.
339
+ Flag if any cited paper appears to have integrity issues.
340
+ This is a lightweight signal β€” not a definitive retraction check.
341
+ Full retraction database integration is a roadmap item.
342
+ """
343
+ flags = []
344
+ if not author_name or not citations:
345
+ return flags
346
+
347
+ try:
348
+ surname = author_name.strip().split()[-1]
349
+ query = urllib.parse.quote(surname)
350
+ url = (
351
+ f"{self._semantic_scholar_url}"
352
+ f"?query={query}&fields=title,year,authors&limit=5"
353
+ )
354
+
355
+ req = urllib.request.Request(
356
+ url,
357
+ headers={"User-Agent": "SciPeerAI/0.1 Research Tool"}
358
+ )
359
+ with urllib.request.urlopen(req, timeout=10) as resp:
360
+ data = json.loads(resp.read().decode())
361
+
362
+ # if we get results β€” API is live β€” note it worked
363
+ # full retraction checking needs Retraction Watch API
364
+ # which requires institutional access
365
+ if data.get("data"):
366
+ pass # API working β€” retraction DB integration: Phase D
367
+
368
+ except Exception:
369
+ pass # external API down β€” silent fail, not critical
370
+
371
+ return flags
372
+
373
+ # ── scoring ───────────────────────────────────────────────
374
+
375
+ def _calculate_risk(self, flags: list) -> float:
376
+ weights = {"high": 0.35, "medium": 0.20, "low": 0.08}
377
+ score = sum(weights.get(f.severity, 0) for f in flags)
378
+ return min(score, 1.0)
379
+
380
+ def _get_risk_level(self, score: float) -> str:
381
+ if score >= 0.7: return "critical"
382
+ elif score >= 0.4: return "high"
383
+ elif score >= 0.2: return "medium"
384
+ return "low"
385
+
386
+ def _write_summary(
387
+ self, flags: list, risk_level: str, total: int
388
+ ) -> str:
389
+ if not flags:
390
+ return (
391
+ f"Analyzed {total} citation(s). "
392
+ f"No citation integrity issues detected."
393
+ )
394
+
395
+ high = sum(1 for f in flags if f.severity == "high")
396
+ med = sum(1 for f in flags if f.severity == "medium")
397
+ parts = []
398
+ if high: parts.append(
399
+ f"{high} high-severity issue{'s' if high > 1 else ''}"
400
+ )
401
+ if med: parts.append(
402
+ f"{med} medium-severity concern{'s' if med > 1 else ''}"
403
+ )
404
+
405
+ return (
406
+ f"Analyzed {total} citation(s). "
407
+ f"Citation audit flagged {', '.join(parts)}. "
408
+ f"Risk level: {risk_level.upper()}."
409
+ )
410
+
411
+
412
+ # ── fix missing import ────────────────────────────────────────
413
+ import urllib.parse
src/scipeerai/modules/citation_cartel.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ο»Ώ# src/scipeerai/modules/citation_cartel.py
2
+ #
3
+ # Citation Cartel Detector
4
+ # Detects suspicious citation network patterns:
5
+ # - Authors citing only each other (cartels)
6
+ # - Excessive self-citation clusters
7
+ # - Citation ring patterns
8
+ # - Suspiciously narrow citation networks
9
+ #
10
+ # Based on network analysis techniques used in
11
+ # Nature/Science level integrity research.
12
+ # Nobody implements this freely.
13
+
14
+ import re
15
+ import math
16
+ from collections import defaultdict
17
+ from dataclasses import dataclass, field
18
+
19
+
20
+ @dataclass
21
+ class CartelFlag:
22
+ flag_type: str
23
+ severity: str
24
+ description: str
25
+ evidence: str
26
+ suggestion: str
27
+
28
+
29
+ @dataclass
30
+ class CartelResult:
31
+ authors_found: list
32
+ citation_network: dict
33
+ cartel_score: float
34
+ self_citation_ratio: float
35
+ network_diversity: float
36
+ risk_level: str
37
+ summary: str
38
+ flags: list = field(default_factory=list)
39
+ flags_count: int = 0
40
+
41
+
42
+ class CitationCartelDetector:
43
+ """
44
+ Citation Cartel Detector.
45
+ Analyzes citation patterns to detect cartels Ò€”
46
+ groups of authors who exclusively cite each other
47
+ to artificially inflate impact metrics.
48
+
49
+ Key signals:
50
+ - High ratio of citations to same small group
51
+ - Same author names appearing repeatedly
52
+ - Narrow institutional citation network
53
+ - Reciprocal citation patterns
54
+ """
55
+
56
+ # Author name patterns in references
57
+ AUTHOR_PAT = re.compile(
58
+ r'([A-Z][a-z]+(?:\s+[A-Z][a-z]*\.?)+)',
59
+ )
60
+
61
+ CITE_PAT = re.compile(
62
+ r'([A-Z][a-z]+(?:\s+et\s+al\.?)?)\s*[\(\[,]?\s*(\d{4})[\)\]]?',
63
+ re.IGNORECASE
64
+ )
65
+ CITE_PAT_PLAIN = re.compile(
66
+ r'([A-Z][a-z]+(?:\s+et\s+al\.?)?)\s+(\d{4})(?:\b)',
67
+ re.IGNORECASE
68
+ )
69
+
70
+
71
+ # Et al pattern
72
+ ETAL_PAT = re.compile(
73
+ r'([A-Z][a-z]+)\s+et\s+al\.?\s*[\(\[,]?\s*(\d{4})',
74
+ re.IGNORECASE
75
+ )
76
+
77
+ # Reference section markers
78
+ REF_MARKERS = re.compile(
79
+ r'\b(?:references|bibliography|works\s+cited)\b',
80
+ re.IGNORECASE
81
+ )
82
+
83
+ def analyze(self, text: str) -> CartelResult:
84
+ citations = self._extract_citations(text)
85
+ authors = self._extract_authors(citations)
86
+ network = self._build_network(citations)
87
+ flags = []
88
+
89
+ # Ò”€Ò”€ Analysis Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
90
+ self_cite_ratio = self._self_citation_ratio(citations, text)
91
+ diversity = self._network_diversity(network, citations)
92
+ concentration = self._concentration_score(network, citations)
93
+ reciprocal = self._detect_reciprocal(network)
94
+
95
+ # Ò”€Ò”€ Flag 1: High concentration Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
96
+ if concentration > 0.5 and len(citations) >= 5:
97
+ top_author = max(network, key=lambda a: network[a],
98
+ default=None)
99
+ top_count = network.get(top_author, 0)
100
+ flags.append(CartelFlag(
101
+ flag_type = "citation_concentration",
102
+ severity = "high" if concentration > 0.7 else "medium",
103
+ description = (
104
+ f"Citation network is highly concentrated Ò€” "
105
+ f"{round(concentration*100)}% of citations point "
106
+ f"to a small group of authors. This pattern "
107
+ f"suggests a citation cartel or echo chamber "
108
+ f"rather than broad scholarly engagement."
109
+ ),
110
+ evidence = (
111
+ f"Top cited author: '{top_author}' "
112
+ f"({top_count} citations) | "
113
+ f"Concentration score: {round(concentration*100)}% | "
114
+ f"Total citations analyzed: {len(citations)}"
115
+ ),
116
+ suggestion = (
117
+ "Broaden citation network to include diverse "
118
+ "sources. Avoid citing only colleagues or "
119
+ "institutional partners. Aim for <30% citations "
120
+ "from any single research group."
121
+ ),
122
+ ))
123
+
124
+ # Ò”€Ò”€ Flag 2: Low network diversity Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
125
+ if diversity < 0.3 and len(citations) >= 8:
126
+ flags.append(CartelFlag(
127
+ flag_type = "low_citation_diversity",
128
+ severity = "medium",
129
+ description = (
130
+ f"Citation network diversity score: "
131
+ f"{round(diversity*100)}%. "
132
+ f"Paper draws from a very narrow pool of authors. "
133
+ f"Genuine scholarship typically cites a broad "
134
+ f"range of researchers across institutions."
135
+ ),
136
+ evidence = (
137
+ f"Unique authors cited: {len(network)} | "
138
+ f"Total citations: {len(citations)} | "
139
+ f"Diversity score: {round(diversity*100)}%"
140
+ ),
141
+ suggestion = (
142
+ "Include citations from diverse research groups, "
143
+ "institutions, and geographic regions. "
144
+ "Review recent literature more broadly."
145
+ ),
146
+ ))
147
+
148
+ # Ò”€Ò”€ Flag 3: Excessive self-citation Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
149
+ if self_cite_ratio > 0.3 and len(citations) >= 5:
150
+ flags.append(CartelFlag(
151
+ flag_type = "excessive_self_citation",
152
+ severity = "high" if self_cite_ratio > 0.5 else "medium",
153
+ description = (
154
+ f"Self-citation ratio: {round(self_cite_ratio*100)}%. "
155
+ f"Paper cites the same author(s) excessively Ò€” "
156
+ f"threshold for concern is >30%. This inflates "
157
+ f"citation metrics artificially."
158
+ ),
159
+ evidence = (
160
+ f"Self-citation rate: {round(self_cite_ratio*100)}% "
161
+ f"(acceptable: <20%) | "
162
+ f"Citations analyzed: {len(citations)}"
163
+ ),
164
+ suggestion = (
165
+ "Limit self-citations to genuinely necessary "
166
+ "references. Most journals recommend <20% "
167
+ "self-citation rate."
168
+ ),
169
+ ))
170
+
171
+ # Ò”€Ò”€ Flag 4: Reciprocal citation pattern Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
172
+ if reciprocal:
173
+ flags.append(CartelFlag(
174
+ flag_type = "reciprocal_citation_pattern",
175
+ severity = "medium",
176
+ description = (
177
+ f"Potential reciprocal citation pattern detected. "
178
+ f"Authors: {', '.join(reciprocal[:3])} appear "
179
+ f"multiple times in concentrated citation clusters. "
180
+ f"This may indicate coordinated citation exchange."
181
+ ),
182
+ evidence = (
183
+ f"Repeated author clusters: "
184
+ f"{', '.join(reciprocal[:5])}"
185
+ ),
186
+ suggestion = (
187
+ "Ensure citations reflect genuine intellectual "
188
+ "debt rather than reciprocal agreements. "
189
+ "Disclose any collaborative relationships."
190
+ ),
191
+ ))
192
+
193
+ # Ò”€Ò”€ Flag 5: Too few citations Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
194
+ if len(citations) < 5:
195
+ flags.append(CartelFlag(
196
+ flag_type = "insufficient_citations",
197
+ severity = "low",
198
+ description = (
199
+ f"Only {len(citations)} citation(s) detected. "
200
+ f"Citation cartel analysis requires at least "
201
+ f"5 citations. Paste full references section "
202
+ f"for complete network analysis."
203
+ ),
204
+ evidence = f"{len(citations)} citations found",
205
+ suggestion = (
206
+ "Include full references section for "
207
+ "complete citation network analysis."
208
+ ),
209
+ ))
210
+
211
+ score = self._aggregate_score(
212
+ concentration, diversity, self_cite_ratio, citations
213
+ )
214
+ level = self._risk(score, len(flags))
215
+ summary = self._build_summary(
216
+ citations, network, score, level,
217
+ concentration, diversity
218
+ )
219
+
220
+ return CartelResult(
221
+ authors_found = list(network.keys())[:10],
222
+ citation_network = dict(network),
223
+ cartel_score = round(score, 4),
224
+ self_citation_ratio = round(self_cite_ratio, 4),
225
+ network_diversity = round(diversity, 4),
226
+ risk_level = level,
227
+ summary = summary,
228
+ flags = flags,
229
+ flags_count = len(flags),
230
+ )
231
+
232
+ # Ò”€Ò”€ internal helpers Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€
233
+
234
+ def _extract_citations(self, text: str) -> list:
235
+ citations = []
236
+ seen = set()
237
+ for m in self.CITE_PAT.finditer(text):
238
+ key = (m.group(1).strip().lower(), m.group(2))
239
+ if key not in seen:
240
+ seen.add(key)
241
+ citations.append((m.group(1).strip(), m.group(2)))
242
+ for m in self.ETAL_PAT.finditer(text):
243
+ key = (m.group(1).strip().lower(), m.group(2))
244
+ if key not in seen:
245
+ seen.add(key)
246
+ citations.append((m.group(1).strip(), m.group(2)))
247
+ for m in self.CITE_PAT_PLAIN.finditer(text):
248
+ key = (m.group(1).strip().lower(), m.group(2))
249
+ if key not in seen:
250
+ seen.add(key)
251
+ citations.append((m.group(1).strip(), m.group(2)))
252
+ return citations
253
+
254
+ def _extract_authors(self, citations: list) -> list:
255
+ return list(set(c[0] for c in citations))
256
+
257
+ def _build_network(self, citations: list) -> dict:
258
+ network = defaultdict(int)
259
+ for author, _ in citations:
260
+ network[author] += 1
261
+ return dict(network)
262
+
263
+ def _self_citation_ratio(self, citations: list,
264
+ text: str) -> float:
265
+ if not citations:
266
+ return 0.0
267
+ # Find most frequent author
268
+ network = self._build_network(citations)
269
+ if not network:
270
+ return 0.0
271
+ top_count = max(network.values())
272
+ return min(top_count / len(citations), 1.0)
273
+
274
+ def _network_diversity(self, network: dict,
275
+ citations: list) -> float:
276
+ if not citations:
277
+ return 1.0
278
+ unique_authors = len(network)
279
+ total_cites = len(citations)
280
+ if total_cites == 0:
281
+ return 1.0
282
+ # Shannon entropy normalized
283
+ entropy = 0.0
284
+ for count in network.values():
285
+ p = count / total_cites
286
+ if p > 0:
287
+ entropy -= p * math.log2(p)
288
+ max_entropy = math.log2(max(unique_authors, 1))
289
+ return (entropy / max_entropy) if max_entropy > 0 else 1.0
290
+
291
+ def _concentration_score(self, network: dict,
292
+ citations: list) -> float:
293
+ if not citations or not network:
294
+ return 0.0
295
+ total = len(citations)
296
+ # Top 3 authors' share
297
+ top3 = sorted(network.values(), reverse=True)[:3]
298
+ return sum(top3) / total
299
+
300
+ def _detect_reciprocal(self, network: dict) -> list:
301
+ if not network:
302
+ return []
303
+ avg = sum(network.values()) / len(network)
304
+ return [a for a, c in network.items() if c >= avg * 2]
305
+
306
+ def _aggregate_score(self, concentration: float,
307
+ diversity: float,
308
+ self_cite: float,
309
+ citations: list) -> float:
310
+ if len(citations) < 3:
311
+ return 0.0
312
+ score = (
313
+ concentration * 0.4 +
314
+ (1 - diversity) * 0.3 +
315
+ self_cite * 0.3
316
+ )
317
+ return min(round(score, 4), 1.0)
318
+
319
+ def _risk(self, score: float, flag_count: int) -> str:
320
+ if score >= 0.6 or flag_count >= 3:
321
+ return "critical"
322
+ if score >= 0.4 or flag_count >= 2:
323
+ return "high"
324
+ if score >= 0.2 or flag_count >= 1:
325
+ return "medium"
326
+ return "low"
327
+
328
+ def _build_summary(self, citations, network, score,
329
+ level, concentration, diversity) -> str:
330
+ if len(citations) < 3:
331
+ return (
332
+ "Citation Cartel Analysis: Insufficient citations "
333
+ "detected. Paste full references section for "
334
+ "network analysis. Risk level: LOW."
335
+ )
336
+ pct = round(score * 100)
337
+ return (
338
+ f"Citation network analyzed: {len(citations)} citations, "
339
+ f"{len(network)} unique authors. "
340
+ f"Concentration: {round(concentration*100)}%, "
341
+ f"Diversity: {round(diversity*100)}%. "
342
+ f"Cartel risk score: {pct}%. "
343
+ f"Risk level: {level.upper()}."
344
+ )
src/scipeerai/modules/citation_dna.py ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Citation DNA Analysis
2
+ # ---------------------
3
+ # Every researcher leaves a citation fingerprint.
4
+ # The journals they trust. The authors they follow.
5
+ # The countries they draw from. The decade they anchor to.
6
+ #
7
+ # Two genuinely independent papers on the same topic
8
+ # will share some citations β€” but not 80% of them.
9
+ # When citation overlap is that high, the papers are not
10
+ # independent. They are the same work in a different coat.
11
+ #
12
+ # This module extracts the citation DNA of a paper:
13
+ # the network of who is cited, how often, from where,
14
+ # and how diverse that network is.
15
+ # Then it measures the health of that network.
16
+ #
17
+ # A manipulated reference list has patterns:
18
+ # too concentrated in one journal, one author, one year,
19
+ # one institution β€” or suspiciously identical to another
20
+ # paper the same group published six months earlier.
21
+
22
+ import re
23
+ import math
24
+ from dataclasses import dataclass, field
25
+ from collections import Counter
26
+
27
+
28
+ # ── constants ──────────────────────────────────────────────────────────────────
29
+
30
+ # major journal name fragments β€” used to detect journal concentration
31
+ KNOWN_JOURNALS = [
32
+ "nature", "science", "cell", "lancet", "jama", "nejm",
33
+ "plos", "pnas", "ieee", "acm", "springer", "elsevier",
34
+ "wiley", "oxford", "cambridge", "frontiers", "mdpi",
35
+ "journal of", "proceedings of", "transactions on",
36
+ "review of", "annals of", "advances in",
37
+ ]
38
+
39
+ # country/region markers found in author affiliations and addresses
40
+ COUNTRY_MARKERS = [
41
+ "usa", "united states", "uk", "united kingdom", "china",
42
+ "germany", "france", "japan", "india", "australia",
43
+ "canada", "italy", "spain", "netherlands", "sweden",
44
+ "switzerland", "brazil", "south korea", "russia", "iran",
45
+ ]
46
+
47
+
48
+ # ── data structures ────────────────────────────────────────────────────────────
49
+
50
+ @dataclass
51
+ class CitationNode:
52
+ author: str
53
+ year: int
54
+ journal: str
55
+ raw: str
56
+
57
+
58
+ @dataclass
59
+ class DNAFlag:
60
+ flag_type: str
61
+ severity: str
62
+ description: str
63
+ evidence: str
64
+ suggestion: str
65
+
66
+
67
+ @dataclass
68
+ class CitationDNAResult:
69
+ total_citations: int
70
+ unique_authors: int
71
+ year_distribution: dict
72
+ journal_distribution: dict
73
+ author_concentration: float
74
+ journal_concentration: float
75
+ decade_concentration: float
76
+ geographic_diversity: float
77
+ dna_diversity_score: float
78
+ dna_risk_score: float
79
+ risk_level: str
80
+ summary: str
81
+ flags: list
82
+ flags_count: int
83
+
84
+
85
+ # ── main class ────────────────────────────────────────────────────────────────
86
+
87
+ class CitationDNAAnalyzer:
88
+ """
89
+ Extracts and evaluates the citation network of a paper.
90
+
91
+ Four dimensions of analysis:
92
+ 1. Author concentration β€” too many citations to one person
93
+ 2. Journal concentration β€” too many citations from one outlet
94
+ 3. Temporal concentration β€” all citations from one decade
95
+ 4. Geographic diversity β€” single-country citation network
96
+
97
+ A healthy reference list is diverse across all four dimensions.
98
+ Concentration in any dimension is a manipulation signal.
99
+ """
100
+
101
+ def analyze(self, text: str) -> CitationDNAResult:
102
+ nodes = self._extract_citation_nodes(text)
103
+ flags = []
104
+ if not nodes:
105
+ return CitationDNAResult(
106
+ total_citations = 0,
107
+ unique_authors = 0,
108
+ year_distribution = {},
109
+ journal_distribution = {},
110
+ author_concentration = 0.0,
111
+ journal_concentration = 0.0,
112
+ decade_concentration = 0.0,
113
+ geographic_diversity = 0.0,
114
+ dna_diversity_score = 1.0,
115
+ dna_risk_score = 0.0,
116
+ risk_level = "low",
117
+ summary = (
118
+ "Citation DNA Analysis: No citations extracted. "
119
+ "Paste the full references section for complete "
120
+ "network analysis. Risk level: LOW."
121
+ ),
122
+ flags = [],
123
+ flags_count = 0,
124
+ )
125
+ author_conc = self._measure_author_concentration(nodes, flags)
126
+ journal_conc = self._measure_journal_concentration(nodes, flags)
127
+ decade_conc = self._measure_decade_concentration(nodes, flags)
128
+ geo_div = self._measure_geographic_diversity(text, flags)
129
+
130
+ year_dist = self._year_distribution(nodes)
131
+ journal_dist = self._journal_distribution(nodes)
132
+
133
+ diversity = self._compute_diversity(
134
+ author_conc, journal_conc, decade_conc, geo_div
135
+ )
136
+ risk_score = 1.0 - diversity
137
+ risk_level = self._get_risk_level(risk_score)
138
+
139
+ return CitationDNAResult(
140
+ total_citations = len(nodes),
141
+ unique_authors = len({n.author for n in nodes if n.author}),
142
+ year_distribution = year_dist,
143
+ journal_distribution = journal_dist,
144
+ author_concentration = round(author_conc, 3),
145
+ journal_concentration = round(journal_conc, 3),
146
+ decade_concentration = round(decade_conc, 3),
147
+ geographic_diversity = round(geo_div, 3),
148
+ dna_diversity_score = round(diversity, 3),
149
+ dna_risk_score = round(risk_score, 3),
150
+ risk_level = risk_level,
151
+ summary = self._write_summary(
152
+ nodes, flags, risk_level, diversity
153
+ ),
154
+ flags = flags,
155
+ flags_count = len(flags),
156
+ )
157
+
158
+ # ── citation extraction ────────────────────────────────────────────────────
159
+
160
+ def _extract_citation_nodes(self, text: str) -> list:
161
+ """
162
+ Parse individual citations from the paper text.
163
+ Handles author-year inline style and numbered reference lists.
164
+ Each node carries author name, year, and guessed journal.
165
+ """
166
+ nodes = []
167
+
168
+ # inline style: Smith (2018), Jones et al. (2020)
169
+ for match in re.finditer(
170
+ r'([A-Z][a-zA-Z\-]+)(?:\s+et\s+al\.?)?\s+\((\d{4})\)',
171
+ text
172
+ ):
173
+ author = match.group(1).lower()
174
+ year = self._safe_year(match.group(2))
175
+ if year:
176
+ nodes.append(CitationNode(
177
+ author = author,
178
+ year = year,
179
+ journal = self._guess_journal(text, match.start()),
180
+ raw = match.group(0),
181
+ ))
182
+
183
+ # if no inline citations found, try reference list parsing
184
+ if not nodes:
185
+ ref_section = self._get_ref_section(text)
186
+ if ref_section:
187
+ nodes = self._parse_reference_list(ref_section)
188
+
189
+ return nodes
190
+
191
+ def _parse_reference_list(self, ref_text: str) -> list:
192
+ """
193
+ Extract structured nodes from a formatted reference list.
194
+ Works on numbered lists like [1] Author, A. (Year). Title. Journal.
195
+ """
196
+ nodes = []
197
+ lines = ref_text.split("\n")
198
+
199
+ for line in lines:
200
+ line = line.strip()
201
+ if len(line) < 20:
202
+ continue
203
+ year_match = re.search(r'\((\d{4})\)', line)
204
+ if not year_match:
205
+ year_match = re.search(r'\b(19[5-9]\d|20[0-2]\d)\b', line)
206
+ if not year_match:
207
+ continue
208
+
209
+ year = self._safe_year(year_match.group(1))
210
+ if not year:
211
+ continue
212
+
213
+ # first capitalized word as author proxy
214
+ author_match = re.search(r'[A-Z][a-z]+', line)
215
+ author = author_match.group(0).lower() if author_match else "unknown"
216
+
217
+ journal = ""
218
+ for known in KNOWN_JOURNALS:
219
+ if known in line.lower():
220
+ journal = known
221
+ break
222
+
223
+ nodes.append(CitationNode(
224
+ author = author,
225
+ year = year,
226
+ journal = journal,
227
+ raw = line[:80],
228
+ ))
229
+
230
+ return nodes
231
+
232
+ def _guess_journal(self, text: str, position: int) -> str:
233
+ """
234
+ Look in a window around the citation for a known journal name.
235
+ Used for inline citations that do not have full reference data.
236
+ """
237
+ window = text[max(0, position - 100): position + 200].lower()
238
+ for journal in KNOWN_JOURNALS:
239
+ if journal in window:
240
+ return journal
241
+ return ""
242
+
243
+ def _get_ref_section(self, text: str) -> str:
244
+ text_lo = text.lower()
245
+ for marker in ["references", "bibliography", "works cited"]:
246
+ idx = text_lo.rfind(marker)
247
+ if idx != -1:
248
+ return text[idx:]
249
+ return ""
250
+
251
+ def _safe_year(self, raw: str) -> int:
252
+ try:
253
+ year = int(raw.strip())
254
+ if 1900 <= year <= 2100:
255
+ return year
256
+ except (ValueError, AttributeError):
257
+ pass
258
+ return 0
259
+
260
+ # ── concentration measures ─────────────────────────────────────────────────
261
+
262
+ def _measure_author_concentration(
263
+ self, nodes: list, flags: list
264
+ ) -> float:
265
+ """
266
+ Herfindahl-Hirschman Index on cited authors.
267
+ HHI near 1.0 means one author dominates the reference list.
268
+ A genuine literature review draws from many different voices.
269
+ """
270
+ if not nodes:
271
+ return 0.0
272
+
273
+ authors = [n.author for n in nodes if n.author]
274
+ if not authors:
275
+ return 0.0
276
+
277
+ hhi = self._hhi(authors)
278
+
279
+ if hhi >= 0.25:
280
+ counter = Counter(authors)
281
+ top_name, top_count = counter.most_common(1)[0]
282
+ ratio = top_count / len(authors)
283
+ flags.append(DNAFlag(
284
+ flag_type = "author_concentration",
285
+ severity = "high" if hhi >= 0.40 else "medium",
286
+ description = (
287
+ f"Citation network is concentrated around a small number "
288
+ f"of authors. HHI concentration index: {round(hhi, 3)}. "
289
+ f"Diverse literature reviews have HHI below 0.10."
290
+ ),
291
+ evidence = (
292
+ f"Most cited author: '{top_name}' appears in "
293
+ f"{top_count}/{len(authors)} citations "
294
+ f"({round(ratio * 100, 1)}%)."
295
+ ),
296
+ suggestion = (
297
+ "Broaden the reference list to include a wider range "
298
+ "of authors. High author concentration may indicate "
299
+ "citation cartel behavior or incomplete literature review."
300
+ ),
301
+ ))
302
+
303
+ return hhi
304
+
305
+ def _measure_journal_concentration(
306
+ self, nodes: list, flags: list
307
+ ) -> float:
308
+ """
309
+ Measures how many citations come from a single journal or publisher.
310
+ A paper citing 80% of its sources from one journal is suspicious.
311
+ """
312
+ if not nodes:
313
+ return 0.0
314
+
315
+ journals = [n.journal for n in nodes if n.journal]
316
+ if len(journals) < 3:
317
+ return 0.0
318
+
319
+ hhi = self._hhi(journals)
320
+
321
+ if hhi >= 0.30:
322
+ counter = Counter(journals)
323
+ top_j, top_count = counter.most_common(1)[0]
324
+ flags.append(DNAFlag(
325
+ flag_type = "journal_concentration",
326
+ severity = "medium",
327
+ description = (
328
+ f"Citations are heavily concentrated in one journal "
329
+ f"or publisher. Journal concentration HHI: {round(hhi, 3)}."
330
+ ),
331
+ evidence = (
332
+ f"Most cited outlet: '{top_j}' accounts for "
333
+ f"{top_count}/{len(journals)} identified journal citations."
334
+ ),
335
+ suggestion = (
336
+ "A balanced reference list draws from multiple journals "
337
+ "across different publishers. Single-outlet concentration "
338
+ "may reflect editorial bias or citation ring behavior."
339
+ ),
340
+ ))
341
+
342
+ return hhi
343
+
344
+ def _measure_decade_concentration(
345
+ self, nodes: list, flags: list
346
+ ) -> float:
347
+ """
348
+ Maps citations to decades and measures concentration.
349
+ A paper claiming to survey a field but citing only one decade
350
+ is presenting an incomplete β€” possibly cherry-picked β€” picture.
351
+ """
352
+ if not nodes:
353
+ return 0.0
354
+
355
+ decades = [
356
+ f"{(n.year // 10) * 10}s"
357
+ for n in nodes if n.year
358
+ ]
359
+ if not decades:
360
+ return 0.0
361
+
362
+ hhi = self._hhi(decades)
363
+
364
+ if hhi >= 0.60:
365
+ counter = Counter(decades)
366
+ top_d, top_count = counter.most_common(1)[0]
367
+ flags.append(DNAFlag(
368
+ flag_type = "temporal_concentration",
369
+ severity = "medium",
370
+ description = (
371
+ f"Citations cluster heavily in one decade. "
372
+ f"Temporal concentration HHI: {round(hhi, 3)}. "
373
+ f"A thorough literature review spans multiple decades."
374
+ ),
375
+ evidence = (
376
+ f"Dominant decade: {top_d} β€” "
377
+ f"{top_count}/{len(decades)} citations "
378
+ f"({round(top_count/len(decades)*100, 1)}%)."
379
+ ),
380
+ suggestion = (
381
+ "Include foundational older works and recent advances "
382
+ "to demonstrate comprehensive literature coverage."
383
+ ),
384
+ ))
385
+
386
+ return hhi
387
+
388
+ def _measure_geographic_diversity(
389
+ self, text: str, flags: list
390
+ ) -> float:
391
+ """
392
+ Scans the text for country/region markers to estimate how
393
+ internationally diverse the cited work is.
394
+ Single-country citation networks are a known bias signal.
395
+ """
396
+ text_lo = text.lower()
397
+ found = [c for c in COUNTRY_MARKERS if c in text_lo]
398
+ unique = len(set(found))
399
+
400
+ # diversity score: 0 countries = 0.0, 5+ countries = 1.0
401
+ diversity = min(unique / 5.0, 1.0)
402
+
403
+ if diversity < 0.20 and unique <= 1:
404
+ flags.append(DNAFlag(
405
+ flag_type = "low_geographic_diversity",
406
+ severity = "low",
407
+ description = (
408
+ f"Citation network shows low geographic diversity. "
409
+ f"Only {unique} country/region marker(s) detected. "
410
+ f"International research should draw from global literature."
411
+ ),
412
+ evidence = (
413
+ f"Countries/regions detected: "
414
+ f"{found if found else 'none identified'}."
415
+ ),
416
+ suggestion = (
417
+ "Consider including research from institutions in "
418
+ "different countries to strengthen international validity."
419
+ ),
420
+ ))
421
+
422
+ return diversity
423
+
424
+ # ── distributions ──────────────────────────────────────────────────────────
425
+
426
+ def _year_distribution(self, nodes: list) -> dict:
427
+ years = [n.year for n in nodes if n.year]
428
+ return dict(sorted(Counter(years).items()))
429
+
430
+ def _journal_distribution(self, nodes: list) -> dict:
431
+ journals = [n.journal for n in nodes if n.journal]
432
+ return dict(Counter(journals).most_common(10))
433
+
434
+ # ── hhi index ─────────────────────────────────────────────────────────────
435
+
436
+ def _hhi(self, items: list) -> float:
437
+ """
438
+ Herfindahl-Hirschman Index β€” standard economics concentration measure.
439
+ Sum of squared market shares. Range: 1/n (perfect diversity) to 1.0 (monopoly).
440
+ We normalize to 0-1 for consistent interpretation.
441
+ """
442
+ if not items:
443
+ return 0.0
444
+ total = len(items)
445
+ counter = Counter(items)
446
+ raw_hhi = sum((count / total) ** 2 for count in counter.values())
447
+ min_hhi = 1.0 / len(counter) if counter else 0.0
448
+ if min_hhi >= 1.0:
449
+ return 1.0
450
+ normalized = (raw_hhi - min_hhi) / (1.0 - min_hhi)
451
+ return round(min(max(normalized, 0.0), 1.0), 4)
452
+
453
+ # ── scoring ────────────────────────────────────────────────────────────────
454
+
455
+ def _compute_diversity(
456
+ self,
457
+ author_conc: float,
458
+ journal_conc: float,
459
+ decade_conc: float,
460
+ geo_div: float,
461
+ ) -> float:
462
+ """
463
+ Diversity is the inverse of concentration across all dimensions.
464
+ Geographic diversity adds to the score rather than subtracting.
465
+ """
466
+ concentration = (
467
+ author_conc * 0.35 +
468
+ journal_conc * 0.30 +
469
+ decade_conc * 0.20
470
+ )
471
+ diversity = (1.0 - concentration) * 0.75 + geo_div * 0.25
472
+ return round(min(max(diversity, 0.0), 1.0), 3)
473
+
474
+ def _get_risk_level(self, score: float) -> str:
475
+ if score >= 0.70: return "critical"
476
+ if score >= 0.45: return "high"
477
+ if score >= 0.25: return "medium"
478
+ return "low"
479
+
480
+ def _write_summary(
481
+ self,
482
+ nodes: list,
483
+ flags: list,
484
+ risk_level: str,
485
+ diversity: float,
486
+ ) -> str:
487
+ n = len(nodes)
488
+ if n == 0:
489
+ return (
490
+ "Citation DNA Analysis: No citations extracted. "
491
+ "Paste the full references section for complete network analysis. "
492
+ f"Risk level: {risk_level.upper()}."
493
+ )
494
+
495
+ if not flags:
496
+ return (
497
+ f"Citation DNA Analysis: {n} citation(s) analyzed. "
498
+ f"Network diversity score: {round(diversity * 100, 1)}%. "
499
+ f"Citation network appears healthy and diverse. "
500
+ f"Risk level: {risk_level.upper()}."
501
+ )
502
+
503
+ high = sum(1 for f in flags if f.severity == "high")
504
+ medium = sum(1 for f in flags if f.severity == "medium")
505
+ parts = []
506
+ if high:
507
+ parts.append(f"{high} high-severity concentration issue{'s' if high > 1 else ''}")
508
+ if medium:
509
+ parts.append(f"{medium} medium-severity pattern{'s' if medium > 1 else ''}")
510
+
511
+ return (
512
+ f"Citation DNA Analysis: {n} citation(s) analyzed. "
513
+ f"Network diversity: {round(diversity * 100, 1)}%. "
514
+ f"{', '.join(parts)} detected. "
515
+ f"Risk level: {risk_level.upper()}."
516
+ )
src/scipeerai/modules/data_fingerprint.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cross-Paper Data Fingerprinting
2
+ # --------------------------------
3
+ # Independent studies on the same topic will produce
4
+ # similar but never identical numbers.
5
+ # Random sampling variation guarantees this.
6
+ #
7
+ # When two papers report the exact same mean to four
8
+ # decimal places, the same standard deviation, the same
9
+ # sample size, and the same p-value β€” they are not
10
+ # independent. One copied from the other, or both
11
+ # copied from a shared fabricated source.
12
+ #
13
+ # This module extracts the numerical fingerprint of a
14
+ # single paper: every mean, SD, sample size, percentage,
15
+ # correlation, and p-value it reports.
16
+ #
17
+ # That fingerprint can then be compared against others.
18
+ # But even in isolation, the fingerprint reveals problems:
19
+ # numbers that are suspiciously round, values that are
20
+ # mathematically impossible given each other, and
21
+ # distributions of digits that do not look like real data.
22
+ #
23
+ # A paper's numbers should look like they came from
24
+ # the world. When they look like they came from a
25
+ # spreadsheet cell someone typed by hand β€” that is a signal.
26
+
27
+ import re
28
+ import math
29
+ from dataclasses import dataclass, field
30
+ from collections import Counter
31
+
32
+
33
+ # ── data structures ────────────────────────────────────────────────────────────
34
+
35
+ @dataclass
36
+ class NumericFingerprint:
37
+ means: list
38
+ std_devs: list
39
+ sample_sizes: list
40
+ percentages: list
41
+ correlations: list
42
+ p_values: list
43
+ all_decimals: list
44
+
45
+
46
+ @dataclass
47
+ class DataFingerprintFlag:
48
+ flag_type: str
49
+ severity: str
50
+ description: str
51
+ evidence: str
52
+ suggestion: str
53
+
54
+
55
+ @dataclass
56
+ class DataFingerprintResult:
57
+ fingerprint: NumericFingerprint
58
+ total_numbers: int
59
+ round_number_ratio: float
60
+ terminal_digit_bias: float
61
+ impossible_pairs: list
62
+ suspicious_duplicates: list
63
+ fingerprint_score: float
64
+ risk_level: str
65
+ summary: str
66
+ flags: list
67
+ flags_count: int
68
+
69
+
70
+ # ── main class ────────────────────────────────────────────────────────────────
71
+
72
+ class DataFingerprintAnalyzer:
73
+ """
74
+ Extracts the complete numerical fingerprint of a paper
75
+ and tests it for signs of fabrication or cloning.
76
+
77
+ Four detection layers:
78
+ 1. Round number bias β€” fabricated data rounds too cleanly
79
+ 2. Terminal digit bias β€” humans avoid certain ending digits
80
+ 3. Impossible value pairs β€” SD larger than mean for positive scales
81
+ 4. Suspicious internal duplicates β€” same value repeated too often
82
+ """
83
+
84
+ # regex patterns for specific statistical values
85
+ _MEAN_PATTERN = re.compile(
86
+ r'(?:mean|average|M)\s*[=:]\s*(-?\d+\.?\d*)', re.IGNORECASE
87
+ )
88
+ _SD_PATTERN = re.compile(
89
+ r'(?:SD|S\.D\.|std|standard deviation)\s*[=:]\s*(\d+\.?\d*)',
90
+ re.IGNORECASE
91
+ )
92
+ _N_PATTERN = re.compile(
93
+ r'(?:N|n|sample size)\s*[=:]\s*(\d+)', re.IGNORECASE
94
+ )
95
+ _PCT_PATTERN = re.compile(
96
+ r'(\d+\.?\d*)\s*%'
97
+ )
98
+ _CORR_PATTERN = re.compile(
99
+ r'(?:r|correlation)\s*[=:]\s*(-?\d*\.?\d+)', re.IGNORECASE
100
+ )
101
+ _PVAL_PATTERN = re.compile(
102
+ r'p\s*[=<>]\s*(0?\.\d+|\d+\.\d+[eE][+-]?\d+)', re.IGNORECASE
103
+ )
104
+
105
+ def analyze(self, text: str) -> DataFingerprintResult:
106
+ fp = self._extract_fingerprint(text)
107
+ flags = []
108
+
109
+ round_ratio = self._check_round_number_bias(fp, flags)
110
+ terminal_bias = self._check_terminal_digit_bias(fp, flags)
111
+ impossible = self._check_impossible_pairs(fp, flags)
112
+ duplicates = self._check_suspicious_duplicates(fp, flags)
113
+
114
+ total = self._count_total(fp)
115
+ score = self._compute_score(
116
+ round_ratio, terminal_bias, impossible, duplicates, total
117
+ )
118
+ level = self._get_risk_level(score)
119
+
120
+ return DataFingerprintResult(
121
+ fingerprint = fp,
122
+ total_numbers = total,
123
+ round_number_ratio = round(round_ratio, 3),
124
+ terminal_digit_bias = round(terminal_bias, 3),
125
+ impossible_pairs = impossible,
126
+ suspicious_duplicates = duplicates,
127
+ fingerprint_score = round(score, 3),
128
+ risk_level = level,
129
+ summary = self._write_summary(flags, level, total),
130
+ flags = flags,
131
+ flags_count = len(flags),
132
+ )
133
+
134
+ # ── extraction ─────────────────────────────────────────────────────────────
135
+
136
+ def _extract_fingerprint(self, text: str) -> NumericFingerprint:
137
+ means = self._parse_floats(self._MEAN_PATTERN, text)
138
+ std_devs = self._parse_floats(self._SD_PATTERN, text)
139
+ sample_sizes = self._parse_ints( self._N_PATTERN, text)
140
+ percentages = self._parse_floats(self._PCT_PATTERN, text)
141
+ correlations = self._parse_floats(self._CORR_PATTERN, text)
142
+ p_values = self._parse_floats(self._PVAL_PATTERN, text)
143
+
144
+ # all decimal numbers in the paper for digit-level analysis
145
+ all_decimals = [
146
+ float(m.group())
147
+ for m in re.finditer(r'-?\d+\.\d+', text)
148
+ if self._safe_float(m.group()) is not None
149
+ ]
150
+
151
+ return NumericFingerprint(
152
+ means = means,
153
+ std_devs = std_devs,
154
+ sample_sizes = sample_sizes,
155
+ percentages = percentages,
156
+ correlations = correlations,
157
+ p_values = p_values,
158
+ all_decimals = all_decimals,
159
+ )
160
+
161
+ def _parse_floats(self, pattern: re.Pattern, text: str) -> list:
162
+ results = []
163
+ for match in pattern.finditer(text):
164
+ val = self._safe_float(match.group(1))
165
+ if val is not None:
166
+ results.append(val)
167
+ return results
168
+
169
+ def _parse_ints(self, pattern: re.Pattern, text: str) -> list:
170
+ results = []
171
+ for match in pattern.finditer(text):
172
+ try:
173
+ val = int(match.group(1))
174
+ if 1 <= val <= 1_000_000:
175
+ results.append(val)
176
+ except (ValueError, IndexError):
177
+ pass
178
+ return results
179
+
180
+ def _safe_float(self, raw: str) -> float:
181
+ try:
182
+ return float(raw.strip())
183
+ except (ValueError, AttributeError):
184
+ return None
185
+
186
+ # ── detection checks ───────────────────────────────────────────────────────
187
+
188
+ def _check_round_number_bias(
189
+ self, fp: NumericFingerprint, flags: list
190
+ ) -> float:
191
+ """
192
+ Real data does not round to whole numbers or .5 steps very often.
193
+ When more than 60% of reported values are suspiciously round,
194
+ someone likely typed them rather than computed them.
195
+ """
196
+ all_vals = fp.means + fp.std_devs + fp.percentages
197
+ if len(all_vals) < 4:
198
+ return 0.0
199
+
200
+ round_count = sum(
201
+ 1 for v in all_vals
202
+ if v == round(v, 0) or v == round(v, 1) and str(v).endswith(('0', '5'))
203
+ )
204
+ ratio = round_count / len(all_vals)
205
+
206
+ if ratio >= 0.60:
207
+ flags.append(DataFingerprintFlag(
208
+ flag_type = "round_number_bias",
209
+ severity = "medium",
210
+ description = (
211
+ f"{round_count}/{len(all_vals)} reported values "
212
+ f"({round(ratio * 100, 1)}%) are suspiciously round. "
213
+ f"Real measured data rarely rounds this cleanly."
214
+ ),
215
+ evidence = (
216
+ f"Round values detected among means, SDs, and percentages. "
217
+ f"Round ratio: {round(ratio, 3)}."
218
+ ),
219
+ suggestion = (
220
+ "Verify that reported values are directly from analysis "
221
+ "output, not manually entered approximations."
222
+ ),
223
+ ))
224
+
225
+ return ratio
226
+
227
+ def _check_terminal_digit_bias(
228
+ self, fp: NumericFingerprint, flags: list
229
+ ) -> float:
230
+ """
231
+ The last digit of a truly random number is uniformly distributed
232
+ across 0-9. Humans fabricating numbers unconsciously prefer
233
+ certain digits (0, 5) and avoid others (7, 9).
234
+ A chi-square test on terminal digits detects this.
235
+ """
236
+ all_vals = fp.all_decimals + [float(n) for n in fp.sample_sizes]
237
+ if len(all_vals) < 10:
238
+ return 0.0
239
+
240
+ terminals = []
241
+ for v in all_vals:
242
+ parts = str(abs(v)).replace('.', '')
243
+ if parts:
244
+ terminals.append(int(parts[-1]))
245
+
246
+ if not terminals:
247
+ return 0.0
248
+
249
+ counter = Counter(terminals)
250
+ expected = len(terminals) / 10.0
251
+ chi_sq = sum(
252
+ ((counter.get(d, 0) - expected) ** 2) / expected
253
+ for d in range(10)
254
+ )
255
+
256
+ # chi-square critical value at p=0.05 with 9 df is 16.92
257
+ bias_score = min(chi_sq / 50.0, 1.0)
258
+
259
+ if chi_sq >= 16.92:
260
+ dominant_digit = counter.most_common(1)[0]
261
+ flags.append(DataFingerprintFlag(
262
+ flag_type = "terminal_digit_bias",
263
+ severity = "medium",
264
+ description = (
265
+ f"Terminal digit distribution deviates significantly "
266
+ f"from uniform expectation. "
267
+ f"Chi-square statistic: {round(chi_sq, 2)} "
268
+ f"(critical value: 16.92). "
269
+ f"This pattern is consistent with human number fabrication."
270
+ ),
271
+ evidence = (
272
+ f"Most frequent terminal digit: "
273
+ f"'{dominant_digit[0]}' appears {dominant_digit[1]} times. "
274
+ f"Expected uniform frequency: {round(expected, 1)} each."
275
+ ),
276
+ suggestion = (
277
+ "Re-examine raw data files to confirm reported values "
278
+ "match analysis output. Terminal digit bias is a "
279
+ "well-established fabrication marker."
280
+ ),
281
+ ))
282
+
283
+ return round(bias_score, 3)
284
+
285
+ def _check_impossible_pairs(
286
+ self, fp: NumericFingerprint, flags: list
287
+ ) -> list:
288
+ """
289
+ Statistical relationships constrain what values can coexist.
290
+ SD > mean is impossible for strictly positive Likert-scale data.
291
+ Correlation outside [-1, 1] is mathematically impossible.
292
+ P-value outside [0, 1] cannot exist.
293
+ """
294
+ impossible = []
295
+
296
+ # SD > mean for positive scales (Likert 1-7, reaction times, etc.)
297
+ for mean, sd in zip(fp.means, fp.std_devs):
298
+ if mean > 0 and sd > mean * 2:
299
+ pair = f"M={mean}, SD={sd}"
300
+ impossible.append(pair)
301
+
302
+ if impossible:
303
+ flags.append(DataFingerprintFlag(
304
+ flag_type = "impossible_sd_mean_pair",
305
+ severity = "high",
306
+ description = (
307
+ f"{len(impossible)} mean/SD pair(s) where the standard "
308
+ f"deviation is implausibly large relative to the mean. "
309
+ f"For bounded positive scales, SD > 2*mean is suspicious."
310
+ ),
311
+ evidence = f"Impossible pairs: {impossible[:3]}.",
312
+ suggestion = (
313
+ "Verify these values against the original analysis output. "
314
+ "Large SDs relative to means may indicate data entry error "
315
+ "or scale confusion."
316
+ ),
317
+ ))
318
+
319
+ # correlation outside valid range
320
+ bad_corr = [r for r in fp.correlations if abs(r) > 1.0]
321
+ if bad_corr:
322
+ impossible.extend([f"r={r}" for r in bad_corr])
323
+ flags.append(DataFingerprintFlag(
324
+ flag_type = "impossible_correlation",
325
+ severity = "high",
326
+ description = (
327
+ f"{len(bad_corr)} correlation value(s) outside [-1, 1]. "
328
+ f"These values are mathematically impossible."
329
+ ),
330
+ evidence = f"Invalid correlations: {bad_corr}.",
331
+ suggestion = "Correct these values before submission.",
332
+ ))
333
+
334
+ # p-value outside [0, 1]
335
+ bad_p = [p for p in fp.p_values if p < 0 or p > 1]
336
+ if bad_p:
337
+ impossible.extend([f"p={p}" for p in bad_p])
338
+ flags.append(DataFingerprintFlag(
339
+ flag_type = "impossible_p_value",
340
+ severity = "high",
341
+ description = (
342
+ f"{len(bad_p)} p-value(s) outside [0, 1]. "
343
+ f"These values cannot exist."
344
+ ),
345
+ evidence = f"Invalid p-values: {bad_p}.",
346
+ suggestion = "Check analysis code for unit or scale errors.",
347
+ ))
348
+
349
+ return impossible
350
+
351
+ def _check_suspicious_duplicates(
352
+ self, fp: NumericFingerprint, flags: list
353
+ ) -> list:
354
+ """
355
+ The same specific decimal value appearing 3+ times in a paper
356
+ is unusual unless it is a threshold or constant.
357
+ In fabricated data, a single invented number gets reused.
358
+ """
359
+ all_vals = fp.means + fp.std_devs + fp.percentages + fp.correlations
360
+ if len(all_vals) < 6:
361
+ return []
362
+
363
+ counter = Counter(all_vals)
364
+ duplicates = [
365
+ v for v, count in counter.items()
366
+ if count >= 3 and v not in (0.0, 1.0, 0.5, 100.0, 0.05)
367
+ ]
368
+
369
+ if duplicates:
370
+ flags.append(DataFingerprintFlag(
371
+ flag_type = "suspicious_value_repetition",
372
+ severity = "medium",
373
+ description = (
374
+ f"{len(duplicates)} specific value(s) appear 3 or more "
375
+ f"times across different reported statistics. "
376
+ f"Genuine independent measurements rarely share "
377
+ f"exact decimal values."
378
+ ),
379
+ evidence = (
380
+ f"Repeated values: "
381
+ f"{[round(v, 4) for v in duplicates[:5]]}."
382
+ ),
383
+ suggestion = (
384
+ "Verify that repeated values reflect genuinely "
385
+ "identical measurements and are not copy-paste artifacts."
386
+ ),
387
+ ))
388
+
389
+ return duplicates
390
+
391
+ # ── helpers ────────────────────────────────────────────────────────────────
392
+
393
+ def _count_total(self, fp: NumericFingerprint) -> int:
394
+ return (
395
+ len(fp.means) + len(fp.std_devs) + len(fp.sample_sizes) +
396
+ len(fp.percentages) + len(fp.correlations) + len(fp.p_values)
397
+ )
398
+
399
+ # ── scoring ────────────────────────────────────────────────────────────────
400
+
401
+ def _compute_score(
402
+ self,
403
+ round_ratio: float,
404
+ terminal_bias: float,
405
+ impossible: list,
406
+ duplicates: list,
407
+ total: int,
408
+ ) -> float:
409
+ if total == 0:
410
+ return 0.0
411
+
412
+ impossible_score = min(len(impossible) * 0.25, 1.0)
413
+ duplicate_score = min(len(duplicates) * 0.15, 1.0)
414
+
415
+ score = (
416
+ round_ratio * 0.25 +
417
+ terminal_bias * 0.25 +
418
+ impossible_score * 0.35 +
419
+ duplicate_score * 0.15
420
+ )
421
+ return min(score, 1.0)
422
+
423
+ def _get_risk_level(self, score: float) -> str:
424
+ if score >= 0.70: return "critical"
425
+ if score >= 0.45: return "high"
426
+ if score >= 0.25: return "medium"
427
+ return "low"
428
+
429
+ def _write_summary(
430
+ self, flags: list, risk_level: str, total: int
431
+ ) -> str:
432
+ if total == 0:
433
+ return (
434
+ "Data Fingerprint Analysis: No statistical values extracted. "
435
+ "Include explicit M=, SD=, N=, r=, and p= reporting "
436
+ f"for full analysis. Risk level: {risk_level.upper()}."
437
+ )
438
+
439
+ if not flags:
440
+ return (
441
+ f"Data Fingerprint Analysis: {total} statistical value(s) "
442
+ f"analyzed. No fabrication signals detected. "
443
+ f"Numerical patterns appear consistent with genuine data. "
444
+ f"Risk level: {risk_level.upper()}."
445
+ )
446
+
447
+ high = sum(1 for f in flags if f.severity == "high")
448
+ medium = sum(1 for f in flags if f.severity == "medium")
449
+ parts = []
450
+ if high:
451
+ parts.append(
452
+ f"{high} impossible value{'s' if high > 1 else ''} detected"
453
+ )
454
+ if medium:
455
+ parts.append(
456
+ f"{medium} fabrication signal{'s' if medium > 1 else ''} found"
457
+ )
458
+
459
+ return (
460
+ f"Data Fingerprint Analysis: {total} value(s) analyzed. "
461
+ f"{'; '.join(parts)}. "
462
+ f"Risk level: {risk_level.upper()}."
463
+ )
src/scipeerai/modules/effect_size_validator.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/scipeerai/modules/effect_size_validator.py
2
+ #
3
+ # Effect Size Validator
4
+ # Extracts and validates Cohen's d, r, eta-squared,
5
+ # odds ratios, and performs post-hoc power analysis.
6
+ #
7
+ # Small N + large effect size = fabrication signal.
8
+ # Underpowered studies with significant results = suspect.
9
+
10
+ import re
11
+ import math
12
+ from dataclasses import dataclass, field
13
+
14
+
15
+ @dataclass
16
+ class EffectSizeFlag:
17
+ flag_type: str
18
+ severity: str
19
+ description: str
20
+ evidence: str
21
+ suggestion: str
22
+
23
+
24
+ @dataclass
25
+ class EffectSizeResult:
26
+ effect_sizes_found: list
27
+ power_estimates: list
28
+ inflated_effects: list
29
+ underpowered: list
30
+ effect_score: float
31
+ risk_level: str
32
+ summary: str
33
+ flags: list = field(default_factory=list)
34
+ flags_count: int = 0
35
+
36
+
37
+ class EffectSizeValidator:
38
+ """
39
+ Effect Size Validator.
40
+ Validates reported effect sizes against sample sizes.
41
+ Detects inflated effects and underpowered studies.
42
+
43
+ Key insight:
44
+ - Real large effects (d>0.8) need N>50 to be credible
45
+ - Small N + large effect = likely false positive
46
+ - Significant result + low power = suspicious
47
+ """
48
+
49
+ # Cohen's d pattern
50
+ COHENS_D = re.compile(
51
+ r"cohen['\s]?s?\s*d\s*[=:]\s*(-?\d+\.?\d*)",
52
+ re.IGNORECASE
53
+ )
54
+ # Pearson r
55
+ PEARSON_R = re.compile(
56
+ r"\br\s*[=:]\s*(-?0?\.\d+)",
57
+ re.IGNORECASE
58
+ )
59
+ # Eta squared
60
+ ETA_SQ = re.compile(
61
+ r"eta[Β²2\s-]*squared?\s*[=:]\s*(0?\.\d+)",
62
+ re.IGNORECASE
63
+ )
64
+ # Omega squared
65
+ OMEGA_SQ = re.compile(
66
+ r"omega[Β²2\s-]*squared?\s*[=:]\s*(0?\.\d+)",
67
+ re.IGNORECASE
68
+ )
69
+ # Odds ratio
70
+ ODDS_R = re.compile(
71
+ r"odds\s*ratio\s*[=:]\s*(\d+\.?\d*)",
72
+ re.IGNORECASE
73
+ )
74
+ # Sample size
75
+ N_PAT = re.compile(
76
+ r"\bn\s*[=:]\s*(\d+)",
77
+ re.IGNORECASE
78
+ )
79
+
80
+ # Cohen's benchmarks
81
+ COHENS_BENCHMARKS = {
82
+ "small": 0.2,
83
+ "medium": 0.5,
84
+ "large": 0.8,
85
+ }
86
+
87
+ def analyze(self, text: str) -> EffectSizeResult:
88
+ effects = self._extract_effects(text)
89
+ ns = self._extract_ns(text)
90
+ n_val = min(ns) if ns else None
91
+
92
+ flags = []
93
+ inflated = []
94
+ underpowered = []
95
+ power_ests = []
96
+
97
+ for etype, evalue in effects:
98
+ # ── Power estimation ──────────────────────────────────
99
+ if n_val and etype == "cohens_d":
100
+ power = self._estimate_power(evalue, n_val)
101
+ power_ests.append({
102
+ "effect_type": etype,
103
+ "effect_value": evalue,
104
+ "n": n_val,
105
+ "power": round(power, 3),
106
+ })
107
+
108
+ # ── Flag: inflated effect size ─────────────────────
109
+ if abs(evalue) > 2.0 and n_val < 30:
110
+ inflated.append((etype, evalue, n_val))
111
+ flags.append(EffectSizeFlag(
112
+ flag_type = "inflated_effect_size",
113
+ severity = "high",
114
+ description = (
115
+ f"Cohen's d = {evalue} is extremely large "
116
+ f"with only N = {n_val}. Effect sizes above "
117
+ f"d = 2.0 with small samples are rarely "
118
+ f"genuine β€” likely reflects noise, "
119
+ f"outliers, or fabrication."
120
+ ),
121
+ evidence = (
122
+ f"Cohen's d = {evalue}, N = {n_val} | "
123
+ f"Expected power: {round(power*100)}% | "
124
+ f"Cohen's large effect benchmark: d = 0.8"
125
+ ),
126
+ suggestion = (
127
+ "Report confidence intervals for effect "
128
+ "sizes. Conduct sensitivity analysis. "
129
+ "Verify no outliers are driving the effect."
130
+ ),
131
+ ))
132
+
133
+ # ── Flag: underpowered study ───────────────────────
134
+ elif power < 0.8 and n_val < 50:
135
+ underpowered.append((etype, evalue, n_val, power))
136
+ flags.append(EffectSizeFlag(
137
+ flag_type = "underpowered_study",
138
+ severity = "medium",
139
+ description = (
140
+ f"Study is underpowered (estimated power = "
141
+ f"{round(power*100)}%). With N = {n_val} and "
142
+ f"d = {evalue}, there is only a "
143
+ f"{round(power*100)}% chance of detecting "
144
+ f"a real effect. Significant results from "
145
+ f"underpowered studies are likely false positives."
146
+ ),
147
+ evidence = (
148
+ f"Cohen's d = {evalue}, N = {n_val} | "
149
+ f"Estimated power = {round(power*100)}% "
150
+ f"(recommended minimum: 80%)"
151
+ ),
152
+ suggestion = (
153
+ "Conduct a priori power analysis. "
154
+ "Increase sample size to achieve 80% power. "
155
+ "Report power analysis in methods section."
156
+ ),
157
+ ))
158
+
159
+ # ── Flag: impossible r value ───────────────────────────
160
+ if etype == "pearson_r" and abs(evalue) > 1.0:
161
+ flags.append(EffectSizeFlag(
162
+ flag_type = "impossible_correlation",
163
+ severity = "high",
164
+ description = (
165
+ f"Pearson r = {evalue} is impossible β€” "
166
+ f"correlations must be between -1 and 1. "
167
+ f"This indicates a reporting error or fabrication."
168
+ ),
169
+ evidence = f"r = {evalue} reported",
170
+ suggestion = (
171
+ "Verify raw correlation values. "
172
+ "Check if rΒ² was mistakenly reported as r."
173
+ ),
174
+ ))
175
+
176
+ # ── Flag: suspiciously large eta squared ──────────────
177
+ if etype == "eta_squared" and evalue > 0.5:
178
+ flags.append(EffectSizeFlag(
179
+ flag_type = "large_eta_squared",
180
+ severity = "medium",
181
+ description = (
182
+ f"Eta-squared = {evalue} is unusually large. "
183
+ f"Values above 0.5 are rare in behavioral and "
184
+ f"social science research and warrant scrutiny."
185
+ ),
186
+ evidence = f"Ξ·Β² = {evalue} (large effect threshold: 0.14)",
187
+ suggestion = (
188
+ "Report partial eta-squared separately. "
189
+ "Verify ANOVA calculations and degrees of freedom."
190
+ ),
191
+ ))
192
+
193
+ # ── Flag: no effect sizes reported ────────────────────────
194
+ if len(effects) == 0:
195
+ flags.append(EffectSizeFlag(
196
+ flag_type = "missing_effect_sizes",
197
+ severity = "medium",
198
+ description = (
199
+ "No effect sizes reported in the paper. "
200
+ "Effect sizes (Cohen's d, r, eta-squared) are "
201
+ "essential for interpreting practical significance "
202
+ "and are required by most major journals."
203
+ ),
204
+ evidence = "No Cohen's d, r, or eta-squared found",
205
+ suggestion = (
206
+ "Report effect sizes with confidence intervals "
207
+ "for all primary outcomes. Use Cohen's d for "
208
+ "mean differences, r for correlations."
209
+ ),
210
+ ))
211
+
212
+ score = self._aggregate_score(inflated, underpowered, effects)
213
+ level = self._risk(score, len(inflated), len(underpowered))
214
+ summary = self._build_summary(
215
+ effects, inflated, underpowered, score, level
216
+ )
217
+
218
+ return EffectSizeResult(
219
+ effect_sizes_found = effects,
220
+ power_estimates = power_ests,
221
+ inflated_effects = inflated,
222
+ underpowered = underpowered,
223
+ effect_score = round(score, 4),
224
+ risk_level = level,
225
+ summary = summary,
226
+ flags = flags,
227
+ flags_count = len(flags),
228
+ )
229
+
230
+ # ── internal helpers ─────────────────────────────────────────
231
+
232
+ def _extract_effects(self, text: str) -> list:
233
+ effects = []
234
+ for m in self.COHENS_D.finditer(text):
235
+ try:
236
+ effects.append(("cohens_d", float(m.group(1))))
237
+ except ValueError:
238
+ pass
239
+ for m in self.PEARSON_R.finditer(text):
240
+ try:
241
+ v = float(m.group(1))
242
+ if -1.5 <= v <= 1.5:
243
+ effects.append(("pearson_r", v))
244
+ except ValueError:
245
+ pass
246
+ for m in self.ETA_SQ.finditer(text):
247
+ try:
248
+ effects.append(("eta_squared", float(m.group(1))))
249
+ except ValueError:
250
+ pass
251
+ for m in self.OMEGA_SQ.finditer(text):
252
+ try:
253
+ effects.append(("omega_squared", float(m.group(1))))
254
+ except ValueError:
255
+ pass
256
+ for m in self.ODDS_R.finditer(text):
257
+ try:
258
+ v = float(m.group(1))
259
+ if 0.1 <= v <= 50:
260
+ effects.append(("odds_ratio", v))
261
+ except ValueError:
262
+ pass
263
+ return effects
264
+
265
+ def _extract_ns(self, text: str) -> list:
266
+ ns = []
267
+ for m in self.N_PAT.finditer(text):
268
+ try:
269
+ v = int(m.group(1))
270
+ if 2 <= v <= 100000:
271
+ ns.append(v)
272
+ except ValueError:
273
+ pass
274
+ return ns
275
+
276
+ def _estimate_power(self, d: float, n: int) -> float:
277
+ """
278
+ Approximate statistical power for two-sample t-test.
279
+ Uses normal approximation of non-central t distribution.
280
+ """
281
+ try:
282
+ ncp = abs(d) * math.sqrt(n / 2)
283
+ power = 1 - self._normal_cdf(1.96 - ncp)
284
+ return min(max(power, 0.0), 1.0)
285
+ except Exception:
286
+ return 0.5
287
+
288
+ def _normal_cdf(self, x: float) -> float:
289
+ """Approximation of standard normal CDF."""
290
+ return 0.5 * (1 + math.erf(x / math.sqrt(2)))
291
+
292
+ def _aggregate_score(self, inflated, underpowered,
293
+ effects) -> float:
294
+ if not effects:
295
+ return 0.3
296
+ score = 0.0
297
+ if inflated:
298
+ score += 0.5 * min(len(inflated), 2) / 2
299
+ if underpowered:
300
+ score += 0.3 * min(len(underpowered), 2) / 2
301
+ return min(score, 1.0)
302
+
303
+ def _risk(self, score: float,
304
+ n_inflated: int,
305
+ n_underpowered: int) -> str:
306
+ if n_inflated >= 1 or score >= 0.6:
307
+ return "critical"
308
+ if n_underpowered >= 2 or score >= 0.4:
309
+ return "high"
310
+ if n_underpowered >= 1 or score >= 0.2:
311
+ return "medium"
312
+ return "low"
313
+
314
+ def _build_summary(self, effects, inflated,
315
+ underpowered, score, level) -> str:
316
+ if not effects:
317
+ return (
318
+ "Effect Size Validation: No effect sizes detected. "
319
+ "Cohen's d, r, or eta-squared reporting is recommended "
320
+ "for all primary outcomes. Risk level: MEDIUM."
321
+ )
322
+ pct = round(score * 100)
323
+ return (
324
+ f"Effect Size Validator analyzed {len(effects)} effect "
325
+ f"size(s). {len(inflated)} inflated, "
326
+ f"{len(underpowered)} underpowered study/studies detected. "
327
+ f"Overall risk score: {pct}%. "
328
+ f"Risk level: {level.upper()}."
329
+ )
src/scipeerai/modules/field_saturation.py ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/scipeerai/modules/field_saturation.py
2
+ #
3
+ # Module 23: Field Saturation Detector
4
+ # Detects over-published research topics, measures novelty space,
5
+ # identifies redundant contribution patterns, and scores field
6
+ # crowding relative to claimed contributions.
7
+ #
8
+ # Score attribute: saturation_score (0.0 = fresh field, 1.0 = saturated)
9
+ # Part of SciPeerAI Phase 6 β€” v2.3.0
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import math
15
+ from collections import Counter
16
+ from dataclasses import dataclass
17
+
18
+
19
+ # ── Data classes ──────────────────────────────────────────────────────────────
20
+
21
+ @dataclass
22
+ class SaturationFlag:
23
+ flag_type: str
24
+ severity: str
25
+ description: str
26
+ evidence: str
27
+ suggestion: str
28
+
29
+
30
+ @dataclass
31
+ class SaturationResult:
32
+ topic_keywords: list
33
+ keyword_density: float
34
+ redundancy_score: float
35
+ novelty_claim_score: float
36
+ contribution_vagueness: float
37
+ overcrowding_signals: int
38
+ saturation_score: float
39
+ risk_level: str
40
+ summary: str
41
+ flags: list
42
+ flags_count: int
43
+
44
+
45
+ # ── Compiled patterns ─────────────────────────────────────────────────────────
46
+
47
+ _NOVELTY_CLAIMS = re.compile(
48
+ r'\b(novel|innovative|first|pioneer|unique|original|breakthrough|'
49
+ r'state.of.the.art|cutting.edge|unprecedented|revolutionar\w+|'
50
+ r'new approach|new method|new framework|we propose|we present|'
51
+ r'we introduce|we develop)\b',
52
+ re.IGNORECASE,
53
+ )
54
+
55
+ _SATURATION_SIGNALS = re.compile(
56
+ r'\b(many studies|numerous studies|extensive research|'
57
+ r'widely studied|well.studied|well.known|well.established|'
58
+ r'extensively investigated|much attention|growing body|'
59
+ r'large body of|considerable research|substantial literature|'
60
+ r'abundant literature|intensively studied|heavily researched|'
61
+ r'significant amount of work|proliferation of|surge of interest)\b',
62
+ re.IGNORECASE,
63
+ )
64
+
65
+ _VAGUE_CONTRIBUTION = re.compile(
66
+ r'\b(improve\w*|enhanc\w*|better\w*|outperform\w*|superior\w*|'
67
+ r'more efficient|more effective|more accurate|higher performance|'
68
+ r'significant improvement|notable improvement|'
69
+ r'promising results|competitive results)\b',
70
+ re.IGNORECASE,
71
+ )
72
+
73
+ _KEYWORD_STOP = {
74
+ 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to',
75
+ 'for', 'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were',
76
+ 'be', 'been', 'has', 'have', 'had', 'do', 'does', 'did', 'will',
77
+ 'would', 'could', 'should', 'may', 'might', 'this', 'that', 'these',
78
+ 'those', 'it', 'its', 'we', 'our', 'their', 'as', 'not', 'also',
79
+ 'can', 'which', 'such', 'than', 'other', 'into', 'used', 'using',
80
+ 'based', 'paper', 'study', 'research', 'method', 'approach', 'work',
81
+ 'show', 'shows', 'shown', 'propose', 'present', 'results', 'data',
82
+ }
83
+
84
+ _SENTENCE_SPLIT = re.compile(r'(?<=[.!?])\s+')
85
+ _WORD_RE = re.compile(r'\b[a-zA-Z]{4,}\b')
86
+
87
+
88
+ # ── Engine ────────────────────────────────────────────────────────────────────
89
+
90
+ class FieldSaturationDetector:
91
+ """
92
+ Field Saturation Detector β€” Module 23.
93
+
94
+ Analyses paper text to detect:
95
+ - Over-saturated research topics with redundant contributions
96
+ - Vague novelty claims unsupported by specific advances
97
+ - Crowding signals indicating field congestion
98
+ - Mismatch between claimed novelty and acknowledged saturation
99
+ """
100
+
101
+ def analyze(self, text: str) -> SaturationResult:
102
+ text = (text or "").strip()
103
+ if not text:
104
+ return self._empty_result("No text provided for saturation analysis.")
105
+
106
+ keywords = self._extract_topic_keywords(text)
107
+ keyword_density = self._compute_keyword_density(text, keywords)
108
+ redundancy_score = self._compute_redundancy(text)
109
+ novelty_claim_score = self._compute_novelty_claims(text)
110
+ contribution_vague = self._compute_contribution_vagueness(text)
111
+ overcrowding_signals = self._count_overcrowding_signals(text)
112
+
113
+ saturation_score = self._compute_score(
114
+ keyword_density,
115
+ redundancy_score,
116
+ novelty_claim_score,
117
+ contribution_vague,
118
+ overcrowding_signals,
119
+ )
120
+
121
+ risk_level = (
122
+ "critical" if saturation_score >= 0.75 else
123
+ "high" if saturation_score >= 0.55 else
124
+ "medium" if saturation_score >= 0.30 else
125
+ "low"
126
+ )
127
+
128
+ flags = self._build_flags(
129
+ keywords, keyword_density, redundancy_score,
130
+ novelty_claim_score, contribution_vague,
131
+ overcrowding_signals, saturation_score,
132
+ )
133
+
134
+ summary = self._build_summary(
135
+ keywords, overcrowding_signals,
136
+ redundancy_score, saturation_score, risk_level,
137
+ )
138
+
139
+ return SaturationResult(
140
+ topic_keywords = keywords[:15],
141
+ keyword_density = round(keyword_density, 4),
142
+ redundancy_score = round(redundancy_score, 4),
143
+ novelty_claim_score = round(novelty_claim_score, 4),
144
+ contribution_vagueness = round(contribution_vague, 4),
145
+ overcrowding_signals = overcrowding_signals,
146
+ saturation_score = round(saturation_score, 4),
147
+ risk_level = risk_level,
148
+ summary = summary,
149
+ flags = flags,
150
+ flags_count = len(flags),
151
+ )
152
+
153
+ # ── Extraction ────────────────────────────────────────────────────────────
154
+
155
+ def _extract_topic_keywords(self, text: str) -> list:
156
+ words = _WORD_RE.findall(text.lower())
157
+ cleaned = [w for w in words if w not in _KEYWORD_STOP and len(w) >= 4]
158
+ counter = Counter(cleaned)
159
+ return [w for w, _ in counter.most_common(30) if counter[w] >= 2]
160
+
161
+ # ── Analysis ──────────────────────────────────────────────────────────────
162
+
163
+ def _compute_keyword_density(self, text: str, keywords: list) -> float:
164
+ if not keywords:
165
+ return 0.0
166
+ words = _WORD_RE.findall(text.lower())
167
+ total_words = max(len(words), 1)
168
+ kw_set = set(keywords[:10])
169
+ kw_count = sum(1 for w in words if w in kw_set)
170
+ raw_density = kw_count / total_words
171
+ return round(min(1.0, raw_density * 8), 4)
172
+
173
+ def _compute_redundancy(self, text: str) -> float:
174
+ sentences = _SENTENCE_SPLIT.split(text.strip())
175
+ sentences = [s.strip() for s in sentences if len(s.strip()) > 20]
176
+ if len(sentences) < 4:
177
+ return 0.0
178
+
179
+ word_sets = []
180
+ for s in sentences:
181
+ words = set(_WORD_RE.findall(s.lower())) - _KEYWORD_STOP
182
+ if words:
183
+ word_sets.append(words)
184
+
185
+ if len(word_sets) < 4:
186
+ return 0.0
187
+
188
+ overlap_count = 0
189
+ total_pairs = 0
190
+ for i in range(len(word_sets)):
191
+ for j in range(i + 1, min(i + 6, len(word_sets))):
192
+ a, b = word_sets[i], word_sets[j]
193
+ union = a | b
194
+ intersection = a & b
195
+ if union:
196
+ jaccard = len(intersection) / len(union)
197
+ if jaccard > 0.35:
198
+ overlap_count += 1
199
+ total_pairs += 1
200
+
201
+ if total_pairs == 0:
202
+ return 0.0
203
+ return round(min(1.0, overlap_count / total_pairs * 2.5), 4)
204
+
205
+ def _compute_novelty_claims(self, text: str) -> float:
206
+ words = _WORD_RE.findall(text)
207
+ total_words = max(len(words), 1)
208
+ claim_count = len(_NOVELTY_CLAIMS.findall(text))
209
+ claim_ratio = claim_count / total_words
210
+
211
+ sat_count = len(_SATURATION_SIGNALS.findall(text))
212
+
213
+ if sat_count > 0 and claim_count > 0:
214
+ mismatch = min(1.0, (claim_count / max(sat_count, 1)) * 0.25)
215
+ else:
216
+ mismatch = 0.0
217
+
218
+ base = min(1.0, claim_ratio * 60)
219
+ return round(min(1.0, base * 0.5 + mismatch * 0.5), 4)
220
+
221
+ def _compute_contribution_vagueness(self, text: str) -> float:
222
+ words = _WORD_RE.findall(text)
223
+ total_words = max(len(words), 1)
224
+ vague_count = len(_VAGUE_CONTRIBUTION.findall(text))
225
+ return round(min(1.0, (vague_count / total_words) * 80), 4)
226
+
227
+ def _count_overcrowding_signals(self, text: str) -> int:
228
+ return len(_SATURATION_SIGNALS.findall(text))
229
+
230
+ # ── Scoring ───────────────────────────────────────────────────────────────
231
+
232
+ def _compute_score(
233
+ self,
234
+ keyword_density: float,
235
+ redundancy_score: float,
236
+ novelty_claim_score: float,
237
+ contribution_vague: float,
238
+ overcrowding: int,
239
+ ) -> float:
240
+ score = 0.0
241
+ score += keyword_density * 0.15
242
+ score += redundancy_score * 0.30
243
+ score += novelty_claim_score * 0.25
244
+ score += contribution_vague * 0.20
245
+ score += min(overcrowding * 0.05, 0.20)
246
+ return round(min(1.0, score), 4)
247
+
248
+ # ── Flags ───────────────────────────────────────────────────────���─────────
249
+
250
+ def _build_flags(
251
+ self,
252
+ keywords: list,
253
+ keyword_density: float,
254
+ redundancy_score: float,
255
+ novelty_claim_score: float,
256
+ contribution_vague: float,
257
+ overcrowding: int,
258
+ saturation_score: float,
259
+ ) -> list:
260
+ flags = []
261
+
262
+ if overcrowding >= 3:
263
+ flags.append(SaturationFlag(
264
+ flag_type = "field_overcrowding_acknowledged",
265
+ severity = "high",
266
+ description = (
267
+ f"Paper acknowledges {overcrowding} field saturation signals, "
268
+ f"yet still claims novel contribution β€” a credibility mismatch."
269
+ ),
270
+ evidence = (
271
+ f"{overcrowding} saturation phrases detected: "
272
+ f"'many studies', 'well-studied', 'extensive research', etc. "
273
+ f"Field is crowded by the paper's own admission."
274
+ ),
275
+ suggestion = (
276
+ "Clearly articulate what specific gap this paper fills that "
277
+ "prior work has not. Vague novelty in saturated fields is a "
278
+ "major rejection signal for top journals."
279
+ ),
280
+ ))
281
+
282
+ if redundancy_score >= 0.45:
283
+ flags.append(SaturationFlag(
284
+ flag_type = "high_content_redundancy",
285
+ severity = "medium",
286
+ description = (
287
+ "High sentence-level content redundancy detected β€” paper "
288
+ "repeats concepts across sections without adding new information."
289
+ ),
290
+ evidence = (
291
+ f"Jaccard similarity analysis across sentence windows "
292
+ f"returned redundancy score of {redundancy_score:.2f} "
293
+ f"(threshold: 0.45). Significant content overlap present."
294
+ ),
295
+ suggestion = (
296
+ "Restructure paper to eliminate repetitive content. "
297
+ "Each section should introduce new information, not restate previous points."
298
+ ),
299
+ ))
300
+
301
+ if novelty_claim_score >= 0.45:
302
+ flags.append(SaturationFlag(
303
+ flag_type = "overclaimed_novelty",
304
+ severity = "high",
305
+ description = (
306
+ "Paper makes disproportionately high novelty claims relative "
307
+ "to the specificity of its actual contributions."
308
+ ),
309
+ evidence = (
310
+ f"Novelty claim density score: {novelty_claim_score:.2f}. "
311
+ f"High frequency of terms like 'novel', 'first', 'innovative', "
312
+ f"'breakthrough' without commensurate technical specificity."
313
+ ),
314
+ suggestion = (
315
+ "Replace broad novelty claims with precise technical statements. "
316
+ "Quantify what makes the contribution different, not just that it is different."
317
+ ),
318
+ ))
319
+
320
+ if contribution_vague >= 0.40:
321
+ flags.append(SaturationFlag(
322
+ flag_type = "vague_contribution_language",
323
+ severity = "medium",
324
+ description = (
325
+ "Contribution language is vague and non-specific β€” paper "
326
+ "relies on generic improvement claims rather than measurable advances."
327
+ ),
328
+ evidence = (
329
+ f"Vagueness score: {contribution_vague:.2f}. Overuse of "
330
+ f"'improve', 'enhance', 'better', 'outperform' without "
331
+ f"quantified baselines or specific metrics."
332
+ ),
333
+ suggestion = (
334
+ "State contributions with exact numbers: 'X% improvement on Y benchmark "
335
+ "over Z baseline' instead of 'improved performance'."
336
+ ),
337
+ ))
338
+
339
+ if keyword_density >= 0.55 and len(keywords) >= 5:
340
+ flags.append(SaturationFlag(
341
+ flag_type = "topic_keyword_saturation",
342
+ severity = "low",
343
+ description = (
344
+ "Topic keyword density is high β€” a small set of terms "
345
+ "dominates the entire paper, suggesting narrow scope."
346
+ ),
347
+ evidence = (
348
+ f"Keyword density index: {keyword_density:.2f}. "
349
+ f"Top terms: {', '.join(keywords[:6])}. "
350
+ f"Repeated heavily throughout without broadening context."
351
+ ),
352
+ suggestion = (
353
+ "Contextualize the work within broader research themes. "
354
+ "Narrow keyword scope can signal incremental rather than transformative contribution."
355
+ ),
356
+ ))
357
+
358
+ if not flags:
359
+ flags.append(SaturationFlag(
360
+ flag_type = "field_saturation_acceptable",
361
+ severity = "low",
362
+ description = "No significant field saturation indicators detected.",
363
+ evidence = (
364
+ f"Saturation score: {saturation_score:.2f}. "
365
+ f"Contribution language is sufficiently specific. "
366
+ f"Field crowding signals are within acceptable range."
367
+ ),
368
+ suggestion = "Continue ensuring each section adds incremental specificity.",
369
+ ))
370
+
371
+ return flags
372
+
373
+ # ── Summary ───────────────────────────────────────────────────────────────
374
+
375
+ def _build_summary(
376
+ self,
377
+ keywords: list,
378
+ overcrowding: int,
379
+ redundancy_score: float,
380
+ saturation_score: float,
381
+ risk_level: str,
382
+ ) -> str:
383
+ top_kw = ', '.join(keywords[:5]) if keywords else 'none detected'
384
+ return (
385
+ f"Field saturation analysis complete. "
386
+ f"Top topic keywords: {top_kw}. "
387
+ f"{overcrowding} field crowding signal(s) detected in text. "
388
+ f"Content redundancy score: {redundancy_score:.2f}. "
389
+ f"Overall saturation risk: {risk_level.upper()}."
390
+ )
391
+
392
+ # ── Fallback ──────────────────────────────────────────────────────────────
393
+
394
+ def _empty_result(self, msg: str) -> SaturationResult:
395
+ return SaturationResult(
396
+ topic_keywords = [],
397
+ keyword_density = 0.0,
398
+ redundancy_score = 0.0,
399
+ novelty_claim_score = 0.0,
400
+ contribution_vagueness = 0.0,
401
+ overcrowding_signals = 0,
402
+ saturation_score = 0.0,
403
+ risk_level = "low",
404
+ summary = msg,
405
+ flags = [],
406
+ flags_count = 0,
407
+ )
src/scipeerai/modules/figure_forensics.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Figure Forensics Module
2
+ # -----------------------
3
+ # Scientific image manipulation is one of the hardest
4
+ # fraud types to catch manually. A reviewer comparing
5
+ # 40 gel images across a paper would need hours.
6
+ # We do it in milliseconds.
7
+ #
8
+ # Three things we check:
9
+ # 1. Duplicate/recycled figures (perceptual hashing)
10
+ # 2. Signs of digital editing (Error Level Analysis)
11
+ # 3. Unnatural brightness uniformity (contrast flattening)
12
+
13
+ import io
14
+ import math
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ import fitz # PyMuPDF β€” extract images from PDF
19
+ import imagehash # perceptual hashing
20
+ import numpy as np
21
+ from PIL import Image, ImageFilter
22
+
23
+
24
+ # ── data structures ──────────────────────────────────────────────────────────
25
+
26
+ @dataclass
27
+ class ExtractedFigure:
28
+ page_number: int
29
+ figure_index: int
30
+ width: int
31
+ height: int
32
+ image: Image.Image # actual PIL image object
33
+
34
+
35
+ @dataclass
36
+ class ForensicFlag:
37
+ flag_type: str
38
+ severity: str
39
+ description: str
40
+ evidence: str
41
+ figures_involved: list
42
+
43
+
44
+ @dataclass
45
+ class FigureForensicsResult:
46
+ figures_found: int
47
+ flags: list
48
+ duplicate_pairs: list # list of (fig_a, fig_b) index pairs
49
+ risk_score: float
50
+ risk_level: str
51
+ summary: str
52
+
53
+
54
+ # ── main class ────────────────────────────────────────────────────────────────
55
+
56
+ class FigureForensicsEngine:
57
+ """
58
+ Extracts figures from a PDF and runs forensic analysis on each one.
59
+
60
+ Why class-based: we'll want to tune sensitivity thresholds
61
+ per domain β€” medical imaging needs stricter checks than
62
+ social science bar charts.
63
+ """
64
+
65
+ # two images with hash distance <= this are "suspiciously similar"
66
+ DUPLICATE_HASH_THRESHOLD = 8
67
+
68
+ # images smaller than this are likely icons/logos β€” skip them
69
+ MIN_FIGURE_SIZE = (50, 50)
70
+
71
+ def __init__(self):
72
+ pass
73
+
74
+ # ── public method ─────────────────────────────────────────────────────────
75
+
76
+ def analyze(self, pdf_path: str) -> FigureForensicsResult:
77
+ """
78
+ Full forensic pipeline for a PDF file.
79
+ Extract β†’ Hash β†’ Compare β†’ Analyze β†’ Report
80
+ """
81
+ pdf_path = Path(pdf_path)
82
+ if not pdf_path.exists():
83
+ raise FileNotFoundError(f"PDF not found: {pdf_path}")
84
+
85
+ figures = self._extract_figures(pdf_path)
86
+
87
+ if not figures:
88
+ return FigureForensicsResult(
89
+ figures_found=0,
90
+ flags=[],
91
+ duplicate_pairs=[],
92
+ risk_score=0.0,
93
+ risk_level="low",
94
+ summary="No figures found in this document.",
95
+ )
96
+
97
+ flags = []
98
+ duplicate_pairs = []
99
+
100
+ dup_flags, dup_pairs = self._check_duplicates(figures)
101
+ flags.extend(dup_flags)
102
+ duplicate_pairs.extend(dup_pairs)
103
+
104
+ ela_flags = self._check_ela_anomalies(figures)
105
+ flags.extend(ela_flags)
106
+
107
+ brightness_flags = self._check_brightness_uniformity(figures)
108
+ flags.extend(brightness_flags)
109
+
110
+ risk_score = self._calculate_risk(flags)
111
+ risk_level = self._get_risk_level(risk_score)
112
+
113
+ return FigureForensicsResult(
114
+ figures_found=len(figures),
115
+ flags=flags,
116
+ duplicate_pairs=duplicate_pairs,
117
+ risk_score=round(risk_score, 3),
118
+ risk_level=risk_level,
119
+ summary=self._write_summary(len(figures), flags, risk_level),
120
+ )
121
+
122
+ # ── extraction ────────────────────────────────────────────────────────────
123
+
124
+ def _extract_figures(self, pdf_path: Path) -> list:
125
+ """
126
+ Pull every image out of the PDF, skip tiny ones
127
+ that are probably decorative elements.
128
+ """
129
+ figures = []
130
+ doc = fitz.open(str(pdf_path))
131
+
132
+ for page_num, page in enumerate(doc):
133
+ image_list = page.get_images(full=True)
134
+
135
+ for img_idx, img_ref in enumerate(image_list):
136
+ xref = img_ref[0]
137
+ try:
138
+ base_image = doc.extract_image(xref)
139
+ img_bytes = base_image["image"]
140
+ img = Image.open(io.BytesIO(img_bytes)).convert("RGB")
141
+
142
+ # skip tiny decorative images
143
+ if img.width < self.MIN_FIGURE_SIZE[0]:
144
+ continue
145
+ if img.height < self.MIN_FIGURE_SIZE[1]:
146
+ continue
147
+
148
+ figures.append(ExtractedFigure(
149
+ page_number=page_num + 1,
150
+ figure_index=len(figures),
151
+ width=img.width,
152
+ height=img.height,
153
+ image=img,
154
+ ))
155
+
156
+ except Exception:
157
+ # corrupted or unreadable image β€” skip, don't crash
158
+ continue
159
+
160
+ doc.close()
161
+ return figures
162
+
163
+ # ── forensic checks ───────────────────────────────────────────────────────
164
+
165
+ def _check_duplicates(self, figures: list) -> tuple:
166
+ """
167
+ Perceptual hashing β€” convert each image to a 64-bit hash
168
+ that represents its visual "fingerprint."
169
+
170
+ Unlike MD5 (which changes completely with one pixel edit),
171
+ perceptual hash stays similar if images are visually similar.
172
+ This catches: same image re-saved at different quality,
173
+ cropped versions, brightness-adjusted copies.
174
+ """
175
+ flags = []
176
+ duplicate_pairs = []
177
+
178
+ # compute hash for every figure
179
+ hashes = []
180
+ for fig in figures:
181
+ h = imagehash.phash(fig.image)
182
+ hashes.append(h)
183
+
184
+ # compare every pair β€” O(nΒ²) but papers rarely have >50 figures
185
+ for i in range(len(figures)):
186
+ for j in range(i + 1, len(figures)):
187
+ distance = hashes[i] - hashes[j]
188
+
189
+ if distance <= self.DUPLICATE_HASH_THRESHOLD:
190
+ pair = (figures[i].figure_index, figures[j].figure_index)
191
+ duplicate_pairs.append(pair)
192
+
193
+ severity = "high" if distance <= 4 else "medium"
194
+ flags.append(ForensicFlag(
195
+ flag_type="duplicate_figures",
196
+ severity=severity,
197
+ description=(
198
+ f"Figure on page {figures[i].page_number} and "
199
+ f"figure on page {figures[j].page_number} are "
200
+ f"visually identical or near-identical "
201
+ f"(hash distance: {distance}/64)."
202
+ ),
203
+ evidence=f"Hash distance: {distance}. Threshold: {self.DUPLICATE_HASH_THRESHOLD}",
204
+ figures_involved=[
205
+ figures[i].figure_index,
206
+ figures[j].figure_index
207
+ ],
208
+ ))
209
+
210
+ return flags, duplicate_pairs
211
+
212
+ def _check_ela_anomalies(self, figures: list) -> list:
213
+ """
214
+ Error Level Analysis (ELA) β€” when an image is edited and
215
+ re-saved as JPEG, the edited regions compress differently
216
+ from the original. This creates visible "error level" patterns.
217
+
218
+ High variance in ELA output = suspicious editing.
219
+ """
220
+ flags = []
221
+
222
+ for fig in figures:
223
+ ela_score = self._compute_ela_score(fig.image)
224
+
225
+ if ela_score > 35.0:
226
+ flags.append(ForensicFlag(
227
+ flag_type="ela_anomaly",
228
+ severity="high" if ela_score > 50 else "medium",
229
+ description=(
230
+ f"Figure on page {fig.page_number} shows unusual "
231
+ f"compression artifacts consistent with digital editing. "
232
+ f"ELA score: {round(ela_score, 2)}"
233
+ ),
234
+ evidence=f"ELA variance score: {round(ela_score, 2)} (threshold: 35.0)",
235
+ figures_involved=[fig.figure_index],
236
+ ))
237
+
238
+ return flags
239
+
240
+ def _check_brightness_uniformity(self, figures: list) -> list:
241
+ """
242
+ Legitimately captured images (microscopy, gels, photos)
243
+ have natural brightness variation. An image with extremely
244
+ uniform brightness across all regions suggests artificial
245
+ contrast adjustment or digital fabrication.
246
+ """
247
+ flags = []
248
+
249
+ for fig in figures:
250
+ uniformity_score = self._compute_brightness_uniformity(fig.image)
251
+
252
+ # very high uniformity = suspiciously "perfect" image
253
+ if uniformity_score > 0.92:
254
+ flags.append(ForensicFlag(
255
+ flag_type="unnatural_brightness_uniformity",
256
+ severity="medium",
257
+ description=(
258
+ f"Figure on page {fig.page_number} has unusually "
259
+ f"uniform brightness distribution "
260
+ f"(uniformity: {round(uniformity_score * 100, 1)}%). "
261
+ f"Natural images rarely exceed 85% uniformity."
262
+ ),
263
+ evidence=f"Uniformity score: {round(uniformity_score, 4)}",
264
+ figures_involved=[fig.figure_index],
265
+ ))
266
+
267
+ return flags
268
+
269
+ # ── computation helpers ────────���──────────────────────────────────────────
270
+
271
+ def _compute_ela_score(self, image: Image.Image) -> float:
272
+ """
273
+ Save image at low quality, compare to original.
274
+ Edited regions show higher difference = higher ELA score.
275
+ """
276
+ # save at low quality to JPEG (amplifies compression artifacts)
277
+ buffer = io.BytesIO()
278
+ image.save(buffer, format="JPEG", quality=75)
279
+ buffer.seek(0)
280
+ compressed = Image.open(buffer).convert("RGB")
281
+
282
+ # pixel-wise difference
283
+ orig_arr = np.array(image, dtype=np.float32)
284
+ comp_arr = np.array(compressed, dtype=np.float32)
285
+ diff = np.abs(orig_arr - comp_arr)
286
+
287
+ # standard deviation of the difference β€” high = suspicious
288
+ return float(np.std(diff))
289
+
290
+ def _compute_brightness_uniformity(self, image: Image.Image) -> float:
291
+ """
292
+ Convert to grayscale, measure how "flat" the histogram is.
293
+ A very flat histogram = artificially processed image.
294
+ """
295
+ gray = np.array(image.convert("L"), dtype=np.float32)
296
+ std_dev = np.std(gray)
297
+
298
+ # normalize: low std_dev = high uniformity score
299
+ # 128 is half of 255 β€” a natural image usually has std > 40
300
+ uniformity = 1.0 - min(std_dev / 128.0, 1.0)
301
+ return float(uniformity)
302
+
303
+ # ── scoring ───────────────────────────────────────────────────────────────
304
+
305
+ def _calculate_risk(self, flags: list) -> float:
306
+ weights = {"high": 0.40, "medium": 0.20, "low": 0.08}
307
+ score = sum(weights.get(f.severity, 0) for f in flags)
308
+ return min(score, 1.0)
309
+
310
+ def _get_risk_level(self, score: float) -> str:
311
+ if score >= 0.7:
312
+ return "critical"
313
+ elif score >= 0.4:
314
+ return "high"
315
+ elif score >= 0.2:
316
+ return "medium"
317
+ return "low"
318
+
319
+ def _write_summary(self, fig_count: int, flags: list, risk_level: str) -> str:
320
+ if not flags:
321
+ return (
322
+ f"Analyzed {fig_count} figure(s). "
323
+ f"No forensic anomalies detected."
324
+ )
325
+
326
+ high = sum(1 for f in flags if f.severity == "high")
327
+ med = sum(1 for f in flags if f.severity == "medium")
328
+ parts = []
329
+ if high:
330
+ parts.append(f"{high} high-severity issue{'s' if high > 1 else ''}")
331
+ if med:
332
+ parts.append(f"{med} medium-severity concern{'s' if med > 1 else ''}")
333
+
334
+ return (
335
+ f"Analyzed {fig_count} figure(s). "
336
+ f"Figure forensics flagged {', '.join(parts)}. "
337
+ f"Risk level: {risk_level.upper()}."
338
+ )
src/scipeerai/modules/fraud_fingerprint.py ADDED
@@ -0,0 +1,524 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from dataclasses import dataclass, field
3
+ from typing import List, Dict, Optional
4
+
5
+
6
+ # ── Section markers ───────────────────────────────────────────────────────────
7
+
8
+ SECTION_MARKERS = [
9
+ "abstract", "introduction", "background", "related work",
10
+ "literature review", "methodology", "methods", "materials and methods",
11
+ "experimental setup", "results", "findings", "discussion",
12
+ "conclusion", "conclusions", "references", "acknowledgment",
13
+ "acknowledgements",
14
+ ]
15
+
16
+ EXPECTED_ORDER = [
17
+ "abstract", "introduction", "background", "related work",
18
+ "literature review", "methodology", "methods", "materials and methods",
19
+ "results", "findings", "discussion", "conclusion", "conclusions",
20
+ "references",
21
+ ]
22
+
23
+ # ── Style signal patterns ─────────────────────────────────────────────────────
24
+
25
+ PASSIVE_PAT = re.compile(
26
+ r'\b(?:was|were|is|are|been|being)\s+\w+ed\b',
27
+ re.IGNORECASE,
28
+ )
29
+
30
+ HEDGE_WORDS = [
31
+ "may", "might", "could", "possibly", "perhaps", "suggest",
32
+ "appear", "seem", "likely", "probably", "approximately",
33
+ "indicate", "potential", "tend to",
34
+ ]
35
+
36
+ CITATION_PAT = re.compile(
37
+ r'\[\d+\]|\(\w+,?\s*\d{4}\)|\b\w+\s+\d{4}\b',
38
+ )
39
+
40
+ JARGON_PAT = re.compile(
41
+ r'\b(?:therefore|furthermore|moreover|however|nevertheless'
42
+ r'|consequently|subsequently|notably|specifically|particularly'
43
+ r'|importantly|significantly|substantially)\b',
44
+ re.IGNORECASE,
45
+ )
46
+
47
+ AI_PHRASE_PAT = re.compile(
48
+ r'\b(?:it is worth noting|in conclusion|furthermore|notably'
49
+ r'|it should be noted|taken together|in summary|shed(?:s)? light'
50
+ r'|delve|leverage|utilize|facilitate|groundbreaking|unprecedented'
51
+ r'|robust|comprehensive|state-of-the-art|cutting-edge)\b',
52
+ re.IGNORECASE,
53
+ )
54
+
55
+ AUTHORSHIP_SIGNALS = re.compile(
56
+ r'\b(?:the author|the authors|we|our|I\b)',
57
+ re.IGNORECASE,
58
+ )
59
+
60
+ TEMPLATE_PHRASES = [
61
+ "this paper presents", "in this paper", "this study aims",
62
+ "the main contribution", "to the best of our knowledge",
63
+ "this work proposes", "we propose a novel", "extensive experiments",
64
+ "state of the art", "outperforms existing", "superior performance",
65
+ "promising results", "significantly better", "we demonstrate that",
66
+ "our approach achieves", "our method achieves",
67
+ ]
68
+
69
+
70
+ # ── Data classes ──────────────────────────────────────────────────────────────
71
+
72
+ @dataclass
73
+ class FraudFlag:
74
+ flag_type: str
75
+ severity: str
76
+ description: str
77
+ evidence: str
78
+ suggestion: str
79
+
80
+
81
+ @dataclass
82
+ class FraudFingerprintResult:
83
+ fingerprint_score: float
84
+ risk_level: str
85
+ summary: str
86
+ section_dna: Dict[str, dict]
87
+ anomalies: List[dict]
88
+ flags: List[FraudFlag] = field(default_factory=list)
89
+ flags_count: int = 0
90
+ ai_phrase_count: int = 0
91
+ style_consistency: float = 0.0
92
+ authorship_consistency: float = 0.0
93
+ section_order_correct: bool = True
94
+ template_score: float = 0.0
95
+
96
+
97
+ # ── Main class ────────────────────────────────────────────────────────────────
98
+
99
+ class FraudFingerprinter:
100
+ """
101
+ Fraud Fingerprinting v2.3.4
102
+
103
+ Detects writing style inconsistencies across paper sections.
104
+ Ghost-writing, multi-author style mixing, and AI-generated
105
+ section substitution all produce detectable fingerprint anomalies.
106
+
107
+ v2.3.4 upgrades:
108
+ - 8 style DNA features (was 5)
109
+ - Coefficient of variation scoring β€” works on short text too
110
+ - Z-score per-section outlier detection
111
+ - AI phrase density integrated into fingerprint score
112
+ - _extract_authorship_signals preserved
113
+ - _check_section_order preserved
114
+ - _detect_template_writing preserved
115
+ """
116
+
117
+ def analyze(self, text: str, sections: Optional[dict] = None) -> FraudFingerprintResult:
118
+ # Split into sections if not pre-split
119
+ if not sections:
120
+ sections = self._split_sections(text)
121
+
122
+ # Fallback β€” treat whole text as one section
123
+ if not sections:
124
+ sections = {"full_text": text}
125
+
126
+ # Compute style DNA per section
127
+ dna_map = {}
128
+ for sec_name, sec_text in sections.items():
129
+ if sec_text and len(sec_text.strip()) > 20:
130
+ dna_map[sec_name] = self._compute_style_dna(sec_text)
131
+
132
+ # Compare sections for inconsistency
133
+ inconsistency_score, anomalies = self._compare_sections(dna_map)
134
+
135
+ # Authorship signals
136
+ authorship_score = self._extract_authorship_signals(text, sections)
137
+
138
+ # Section order check
139
+ order_correct, order_flags = self._check_section_order(sections)
140
+
141
+ # Template writing detection
142
+ template_score, template_phrases_found = self._detect_template_writing(text)
143
+
144
+ # AI phrase analysis on full text
145
+ ai_count = len(AI_PHRASE_PAT.findall(text))
146
+ words = text.split()
147
+ n_words = max(len(words), 1)
148
+ ai_density = ai_count / n_words
149
+
150
+ # Combined fingerprint score
151
+ ai_component = min(ai_density * 12, 0.40)
152
+ template_comp = min(template_score * 0.15, 0.15)
153
+ fingerprint_score = min(
154
+ inconsistency_score * 0.55
155
+ + ai_component * 0.25
156
+ + template_comp * 0.10
157
+ + (0.10 if not order_correct else 0.0),
158
+ 1.0
159
+ )
160
+
161
+ # Flags
162
+ flags: List[FraudFlag] = []
163
+
164
+ if inconsistency_score >= 0.45:
165
+ flags.append(FraudFlag(
166
+ flag_type = "style_inconsistency",
167
+ severity = "high" if inconsistency_score >= 0.65 else "medium",
168
+ description = (
169
+ f"Writing style varies significantly across sections "
170
+ f"(inconsistency score: {round(inconsistency_score * 100)}%). "
171
+ f"This pattern is consistent with ghost-writing, "
172
+ f"multi-author section contributions without harmonization, "
173
+ f"or AI-generated section substitution."
174
+ ),
175
+ evidence = (
176
+ f"{len(anomalies)} section-level anomalies detected: "
177
+ + (", ".join(
178
+ f"{a['section']}.{a['feature']} (CV={a['cv']})"
179
+ for a in anomalies[:3]
180
+ ) if anomalies else "style drift across sections")
181
+ ),
182
+ suggestion = (
183
+ "Review sections for stylistic consistency. "
184
+ "Large vocabulary or sentence-length differences between "
185
+ "sections may indicate undisclosed multiple authors or "
186
+ "AI-assisted writing."
187
+ ),
188
+ ))
189
+
190
+ if ai_count >= 5:
191
+ flags.append(FraudFlag(
192
+ flag_type = "ai_phrase_density",
193
+ severity = "high" if ai_count >= 10 else "medium",
194
+ description = (
195
+ f"High density of AI-typical phrases detected "
196
+ f"({ai_count} phrases). "
197
+ f"Pattern consistent with LLM-generated or heavily "
198
+ f"LLM-edited content."
199
+ ),
200
+ evidence = (
201
+ f"AI phrase count: {ai_count} | "
202
+ f"Density: {round(ai_density * 1000, 1)} per 1000 words"
203
+ ),
204
+ suggestion = (
205
+ "Review for AI-generated content. Journals increasingly "
206
+ "require disclosure of AI writing assistance."
207
+ ),
208
+ ))
209
+
210
+ if not order_correct and order_flags:
211
+ flags.append(FraudFlag(
212
+ flag_type = "section_order_anomaly",
213
+ severity = "low",
214
+ description = (
215
+ "Paper sections appear in non-standard order. "
216
+ "This may indicate copy-paste assembly or structural manipulation."
217
+ ),
218
+ evidence = "; ".join(order_flags[:3]),
219
+ suggestion = (
220
+ "Verify that sections follow standard academic structure: "
221
+ "Abstract β†’ Introduction β†’ Methods β†’ Results β†’ Discussion β†’ Conclusion."
222
+ ),
223
+ ))
224
+
225
+ if template_score >= 0.30:
226
+ flags.append(FraudFlag(
227
+ flag_type = "template_writing_detected",
228
+ severity = "medium" if template_score >= 0.50 else "low",
229
+ description = (
230
+ f"High density of template/boilerplate phrases detected "
231
+ f"(score: {round(template_score * 100)}%). "
232
+ f"Pattern associated with paper mills and AI-assisted writing."
233
+ ),
234
+ evidence = (
235
+ f"Template phrases found: "
236
+ f"{', '.join(template_phrases_found[:5])}"
237
+ ),
238
+ suggestion = (
239
+ "Replace generic template phrases with specific, "
240
+ "original descriptions of your methodology and findings."
241
+ ),
242
+ ))
243
+
244
+ if anomalies:
245
+ high_anom = [a for a in anomalies if a.get("severity") == "high"]
246
+ if high_anom:
247
+ flags.append(FraudFlag(
248
+ flag_type = "section_outlier",
249
+ severity = "medium",
250
+ description = (
251
+ f"{len(high_anom)} section(s) show extreme style deviation "
252
+ f"from the rest of the paper."
253
+ ),
254
+ evidence = (
255
+ "; ".join(
256
+ f"Section '{a['section']}' has {a['feature']}="
257
+ f"{a['value']} vs paper mean {a['mean']}"
258
+ for a in high_anom[:2]
259
+ )
260
+ ),
261
+ suggestion = (
262
+ "Inspect flagged sections for authorship consistency. "
263
+ "Outlier sections may have been written by a different person "
264
+ "or generated by AI."
265
+ ),
266
+ ))
267
+
268
+ # Risk level
269
+ risk_level = self._get_risk_level(fingerprint_score)
270
+
271
+ # Summary
272
+ summary = self._build_summary(
273
+ fingerprint_score, risk_level,
274
+ len(anomalies), ai_count, flags
275
+ )
276
+
277
+ return FraudFingerprintResult(
278
+ fingerprint_score = round(fingerprint_score, 4),
279
+ risk_level = risk_level,
280
+ summary = summary,
281
+ section_dna = dna_map,
282
+ anomalies = anomalies,
283
+ flags = flags,
284
+ flags_count = len(flags),
285
+ ai_phrase_count = ai_count,
286
+ style_consistency = round(1.0 - inconsistency_score, 4),
287
+ authorship_consistency = round(authorship_score, 4),
288
+ section_order_correct = order_correct,
289
+ template_score = round(template_score, 4),
290
+ )
291
+
292
+ # ── Private helpers ───────────────────────────────────────────────────────
293
+
294
+ def _split_sections(self, text: str) -> dict:
295
+ """Split paper text into named sections."""
296
+ sections = {}
297
+ text_lower = text.lower()
298
+
299
+ for i, marker in enumerate(SECTION_MARKERS):
300
+ start = text_lower.find(marker)
301
+ if start == -1:
302
+ continue
303
+ end = len(text)
304
+ for next_marker in SECTION_MARKERS[i + 1:]:
305
+ next_idx = text_lower.find(next_marker, start + len(marker))
306
+ if next_idx != -1:
307
+ end = next_idx
308
+ break
309
+ section_text = text[start:end].strip()
310
+ if len(section_text) > 30:
311
+ sections[marker] = section_text
312
+
313
+ return sections
314
+
315
+ def _compute_style_dna(self, text: str) -> dict:
316
+ """
317
+ v2.3.4 β€” 8-feature style DNA.
318
+ Works on both short and long text sections.
319
+ """
320
+ if not text or len(text.strip()) < 10:
321
+ return {}
322
+
323
+ words = text.split()
324
+ sentences = [s.strip() for s in re.split(r'[.!?]+', text) if len(s.strip()) > 5]
325
+ n_words = max(len(words), 1)
326
+ n_sents = max(len(sentences), 1)
327
+
328
+ avg_sent_len = n_words / n_sents
329
+ vocab_diversity = len(set(w.lower() for w in words)) / n_words
330
+ passive_count = len(PASSIVE_PAT.findall(text))
331
+ passive_ratio = passive_count / n_sents
332
+ hedge_count = sum(1 for w in HEDGE_WORDS if w in text.lower())
333
+ hedge_ratio = hedge_count / n_words
334
+ cite_count = len(CITATION_PAT.findall(text))
335
+ citation_density = cite_count / n_words
336
+ jargon_count = len(JARGON_PAT.findall(text))
337
+ jargon_ratio = jargon_count / n_words
338
+ question_ratio = text.count('?') / n_sents
339
+ numbers = re.findall(r'\b\d+\.?\d*\b', text)
340
+ number_density = len(numbers) / n_words
341
+
342
+ return {
343
+ "avg_sentence_len": round(avg_sent_len, 3),
344
+ "vocab_diversity": round(vocab_diversity, 3),
345
+ "passive_ratio": round(passive_ratio, 3),
346
+ "hedge_ratio": round(hedge_ratio, 4),
347
+ "citation_density": round(citation_density, 4),
348
+ "jargon_ratio": round(jargon_ratio, 4),
349
+ "question_ratio": round(question_ratio, 4),
350
+ "number_density": round(number_density, 4),
351
+ }
352
+
353
+ def _compare_sections(self, dna_map: dict) -> tuple:
354
+ """
355
+ v2.3.4 β€” Coefficient of variation scoring.
356
+ Works on both short and long sections.
357
+ """
358
+ sections = {k: v for k, v in dna_map.items() if v}
359
+ if len(sections) < 2:
360
+ return 0.0, []
361
+
362
+ features = [
363
+ "avg_sentence_len", "vocab_diversity", "passive_ratio",
364
+ "hedge_ratio", "citation_density", "jargon_ratio",
365
+ "number_density",
366
+ ]
367
+
368
+ anomalies = []
369
+ feature_scores = []
370
+
371
+ for feat in features:
372
+ vals = [
373
+ dna[feat] for dna in sections.values()
374
+ if feat in dna and dna[feat] is not None
375
+ ]
376
+ if len(vals) < 2:
377
+ continue
378
+
379
+ mean_val = sum(vals) / len(vals)
380
+ if mean_val == 0:
381
+ continue
382
+
383
+ std_val = (sum((v - mean_val) ** 2 for v in vals) / len(vals)) ** 0.5
384
+ cv = std_val / (mean_val + 1e-9)
385
+ feature_scores.append(min(cv, 1.0))
386
+
387
+ if cv > 0.35:
388
+ for sec_name, dna in sections.items():
389
+ if feat not in dna:
390
+ continue
391
+ val = dna[feat]
392
+ z_approx = abs(val - mean_val) / (std_val + 1e-9)
393
+ if z_approx > 1.5:
394
+ anomalies.append({
395
+ "section": sec_name,
396
+ "feature": feat,
397
+ "value": round(val, 4),
398
+ "mean": round(mean_val, 4),
399
+ "cv": round(cv, 3),
400
+ "severity": "high" if cv > 0.60 else "medium",
401
+ })
402
+
403
+ if not feature_scores:
404
+ return 0.0, []
405
+
406
+ high_cv_count = sum(1 for s in feature_scores if s > 0.60)
407
+ med_cv_count = sum(1 for s in feature_scores if 0.35 < s <= 0.60)
408
+ base_score = sum(feature_scores) / len(feature_scores)
409
+ bonus = high_cv_count * 0.12 + med_cv_count * 0.05
410
+ final_score = min(base_score + bonus, 1.0)
411
+
412
+ return round(final_score, 4), anomalies
413
+
414
+ def _extract_authorship_signals(self, text: str, sections: dict) -> float:
415
+ """
416
+ Detect authorship inconsistencies across sections.
417
+ Shifts in first-person vs third-person usage suggest
418
+ different authors wrote different sections.
419
+ """
420
+ if not sections or len(sections) < 2:
421
+ return 1.0
422
+
423
+ section_scores = {}
424
+ for sec_name, sec_text in sections.items():
425
+ if not sec_text:
426
+ continue
427
+ words = sec_text.split()
428
+ n_words = max(len(words), 1)
429
+ we_count = len(re.findall(r'\bwe\b|\bour\b', sec_text, re.IGNORECASE))
430
+ i_count = len(re.findall(r'\bI\b', sec_text))
431
+ the_author = len(re.findall(r'\bthe\s+authors?\b', sec_text, re.IGNORECASE))
432
+ first_person = (we_count + i_count) / n_words
433
+ third_person = the_author / n_words
434
+ section_scores[sec_name] = {
435
+ "first_person": first_person,
436
+ "third_person": third_person,
437
+ }
438
+
439
+ if len(section_scores) < 2:
440
+ return 1.0
441
+
442
+ fp_vals = [v["first_person"] for v in section_scores.values()]
443
+ mean_fp = sum(fp_vals) / len(fp_vals)
444
+ if mean_fp == 0:
445
+ return 1.0
446
+
447
+ std_fp = (sum((v - mean_fp) ** 2 for v in fp_vals) / len(fp_vals)) ** 0.5
448
+ cv_fp = std_fp / (mean_fp + 1e-9)
449
+
450
+ # High CV = inconsistent authorship voice
451
+ return round(max(0.0, 1.0 - min(cv_fp, 1.0)), 4)
452
+
453
+ def _check_section_order(self, sections: dict) -> tuple:
454
+ """
455
+ Verify that sections appear in the expected academic order.
456
+ Out-of-order sections may indicate copy-paste assembly.
457
+ """
458
+ if not sections:
459
+ return True, []
460
+
461
+ found_order = [
462
+ marker for marker in EXPECTED_ORDER
463
+ if marker in sections
464
+ ]
465
+
466
+ if len(found_order) < 2:
467
+ return True, []
468
+
469
+ flags = []
470
+ correct = True
471
+ prev_index = -1
472
+
473
+ for sec in found_order:
474
+ curr_index = EXPECTED_ORDER.index(sec)
475
+ if curr_index < prev_index:
476
+ correct = False
477
+ flags.append(
478
+ f"'{sec}' appears after section(s) that should follow it"
479
+ )
480
+ prev_index = curr_index
481
+
482
+ return correct, flags
483
+
484
+ def _detect_template_writing(self, text: str) -> tuple:
485
+ """
486
+ Detect boilerplate / paper-mill template phrases.
487
+ High density of template phrases is associated with
488
+ AI-generated papers and paper mills.
489
+ """
490
+ text_lower = text.lower()
491
+ found = [p for p in TEMPLATE_PHRASES if p in text_lower]
492
+ words = text.split()
493
+ n_words = max(len(words), 1)
494
+
495
+ # Normalize by text length
496
+ raw_score = len(found) / max(len(TEMPLATE_PHRASES), 1)
497
+ density_pen = min(len(found) / (n_words / 100), 0.5)
498
+ score = min(raw_score * 0.6 + density_pen * 0.4, 1.0)
499
+
500
+ return round(score, 4), found
501
+
502
+ def _get_risk_level(self, score: float) -> str:
503
+ if score >= 0.70: return "critical"
504
+ if score >= 0.45: return "high"
505
+ if score >= 0.20: return "medium"
506
+ return "low"
507
+
508
+ def _build_summary(
509
+ self, score: float, risk: str,
510
+ n_anomalies: int, ai_count: int, flags: list
511
+ ) -> str:
512
+ parts = []
513
+ if n_anomalies:
514
+ parts.append(f"{n_anomalies} style anomaly/anomalies detected")
515
+ if ai_count >= 5:
516
+ parts.append(f"{ai_count} AI-typical phrases found")
517
+ if not parts:
518
+ parts.append("No significant style inconsistencies detected")
519
+
520
+ return (
521
+ f"Fraud Fingerprint analysis: {', '.join(parts)}. "
522
+ f"Fingerprint score: {round(score * 100)}%. "
523
+ f"Risk level: {risk.upper()}."
524
+ )
src/scipeerai/modules/granularity_analyzer.py ADDED
@@ -0,0 +1,369 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import math
3
+ from dataclasses import dataclass, field
4
+ from typing import List, Tuple
5
+
6
+
7
+ # ── Constants ─────────────────────────────────────────────────────────────────
8
+
9
+ BENFORD_EXPECTED = [
10
+ math.log10(1 + 1 / d) for d in range(1, 10)
11
+ ]
12
+
13
+ SUSPICIOUS_DECIMAL_THRESHOLD = 4 # > 4 decimal places is suspicious
14
+ EXTREME_DECIMAL_THRESHOLD = 8 # > 8 decimal places is very suspicious
15
+ MIN_NUMBERS_FOR_BENFORD = 4 # lowered from 5 for better sensitivity
16
+ ROUND_NUMBER_THRESHOLD = 0.6
17
+
18
+
19
+ # ── Data classes ──────────────────────────────────────────────────────────────
20
+
21
+ @dataclass
22
+ class GranularityFlag:
23
+ flag_type: str
24
+ severity: str
25
+ description: str
26
+ evidence: str
27
+ suggestion: str
28
+
29
+
30
+ @dataclass
31
+ class GranularityResult:
32
+ digit_preference_score: float
33
+ benford_score: float
34
+ round_number_ratio: float
35
+ granularity_score: float
36
+ risk_level: str
37
+ summary: str
38
+ flags: List[GranularityFlag] = field(default_factory=list)
39
+ flags_count: int = 0
40
+ suspicious_numbers: List[str] = field(default_factory=list)
41
+ extreme_precision: List[str] = field(default_factory=list)
42
+
43
+
44
+ # ── Main class ────────────────────────────────────────────────────────────────
45
+
46
+ class GranularityAnalyzer:
47
+ """
48
+ Granularity Analyzer v2.3.4
49
+
50
+ Applies Benford's Law and precision analysis to numeric data.
51
+ Real measurements follow predictable digit distributions β€”
52
+ fabricated data does not.
53
+
54
+ v2.3.4 upgrades:
55
+ - Raw string extraction preserves full decimal precision
56
+ - Extreme precision detection (> 8 decimal places)
57
+ - Lower Benford threshold (4 numbers instead of 5)
58
+ - Suspicious precision threshold lowered (> 4 instead of > 5)
59
+ - Terminal digit bias detection improved
60
+ """
61
+
62
+ def analyze(self, text: str) -> GranularityResult:
63
+ raw_numbers, float_numbers = self._extract_numbers(text)
64
+ flags: List[GranularityFlag] = []
65
+ suspicious_numbers: List[str] = []
66
+ extreme_precision: List[str] = []
67
+
68
+ # ── Check 1: Benford's Law ────────────────────────────────────────────
69
+ benford_score, benford_flags = self._check_benford(
70
+ float_numbers, raw_numbers
71
+ )
72
+ flags.extend(benford_flags)
73
+
74
+ # ── Check 2: Round number clustering ─────────────────────────────────
75
+ round_ratio, round_flags = self._check_round_numbers(
76
+ float_numbers, raw_numbers
77
+ )
78
+ flags.extend(round_flags)
79
+
80
+ # ── Check 3: Suspicious precision ────────────────────────────────────
81
+ prec_score, prec_flags, suspicious_numbers, extreme_precision = (
82
+ self._check_suspicious_precision(raw_numbers)
83
+ )
84
+ flags.extend(prec_flags)
85
+
86
+ # ── Check 4: Terminal digit bias ──────────────────────────────────────
87
+ digit_score, digit_flags = self._check_terminal_digit_bias(
88
+ float_numbers, raw_numbers
89
+ )
90
+ flags.extend(digit_flags)
91
+
92
+ # ── Aggregate score ───────────────────────────────────────────────────
93
+ granularity_score = self._compute_score(
94
+ benford_score, round_ratio, prec_score, digit_score
95
+ )
96
+ risk_level = self._get_risk_level(granularity_score)
97
+
98
+ summary = (
99
+ f"Granularity analysis of {len(raw_numbers)} numerical values. "
100
+ f"Anomaly score: {round(granularity_score * 100)}%. "
101
+ f"{len(flags)} granularity concern(s) detected. "
102
+ f"Risk level: {risk_level.upper()}."
103
+ )
104
+
105
+ return GranularityResult(
106
+ digit_preference_score = round(digit_score, 4),
107
+ benford_score = round(benford_score, 4),
108
+ round_number_ratio = round(round_ratio, 4),
109
+ granularity_score = round(granularity_score, 4),
110
+ risk_level = risk_level,
111
+ summary = summary,
112
+ flags = flags,
113
+ flags_count = len(flags),
114
+ suspicious_numbers = suspicious_numbers,
115
+ extreme_precision = extreme_precision,
116
+ )
117
+
118
+ # ── Private helpers ───────────────────────────────────────────────────────
119
+
120
+ def _extract_numbers(self, text: str) -> Tuple[List[str], List[float]]:
121
+ """
122
+ v2.3.4 β€” Extract numbers preserving full raw string.
123
+ Raw strings are used for precision checking.
124
+ Float values are used for Benford and round-number checks.
125
+ """
126
+ # Raw string extraction β€” keeps full decimal precision
127
+ raw_pattern = re.compile(r'\b(\d+\.\d+|\d+)\b')
128
+ raw_numbers = raw_pattern.findall(text)
129
+
130
+ float_numbers = []
131
+ for r in raw_numbers:
132
+ try:
133
+ float_numbers.append(float(r))
134
+ except ValueError:
135
+ pass
136
+
137
+ return raw_numbers, float_numbers
138
+
139
+ def _check_benford(
140
+ self, float_numbers: List[float], raw_numbers: List[str]
141
+ ) -> Tuple[float, List[GranularityFlag]]:
142
+ """Apply Benford's Law to first-digit distribution."""
143
+ flags = []
144
+
145
+ positives = [n for n in float_numbers if n > 0]
146
+ if len(positives) < MIN_NUMBERS_FOR_BENFORD:
147
+ return 0.0, flags
148
+
149
+ first_digits = []
150
+ for n in positives:
151
+ s = str(n).lstrip('0').replace('.', '')
152
+ if s and s[0].isdigit() and s[0] != '0':
153
+ first_digits.append(int(s[0]))
154
+
155
+ if not first_digits:
156
+ return 0.0, flags
157
+
158
+ observed = [
159
+ first_digits.count(d) / len(first_digits)
160
+ for d in range(1, 10)
161
+ ]
162
+
163
+ deviation = sum(
164
+ abs(o - e) for o, e in zip(observed, BENFORD_EXPECTED)
165
+ ) / 9
166
+
167
+ if deviation > 0.15:
168
+ flags.append(GranularityFlag(
169
+ flag_type = "benford_violation",
170
+ severity = "high" if deviation > 0.25 else "medium",
171
+ description = (
172
+ f"First-digit distribution deviates from Benford's Law "
173
+ f"(deviation: {round(deviation, 3)}). "
174
+ f"Natural data follows Benford's Law β€” fabricated numbers often do not."
175
+ ),
176
+ evidence = (
177
+ f"Benford deviation: {round(deviation, 3)} "
178
+ f"(threshold: 0.15) | "
179
+ f"Numbers analyzed: {len(positives)}"
180
+ ),
181
+ suggestion = (
182
+ "Verify all reported numeric values against raw data. "
183
+ "Large Benford deviations are a validated fraud signal."
184
+ ),
185
+ ))
186
+
187
+ return round(deviation, 4), flags
188
+
189
+ def _check_round_numbers(
190
+ self, float_numbers: List[float], raw_numbers: List[str]
191
+ ) -> Tuple[float, List[GranularityFlag]]:
192
+ """Detect suspicious clustering of round numbers."""
193
+ flags = []
194
+
195
+ if not float_numbers:
196
+ return 0.0, flags
197
+
198
+ round_count = sum(
199
+ 1 for n in float_numbers
200
+ if n > 0 and n == int(n) and int(n) % 5 == 0
201
+ )
202
+ round_ratio = round_count / len(float_numbers)
203
+
204
+ if round_ratio > ROUND_NUMBER_THRESHOLD:
205
+ flags.append(GranularityFlag(
206
+ flag_type = "round_number_clustering",
207
+ severity = "medium",
208
+ description = (
209
+ f"Unusually high proportion of round numbers detected "
210
+ f"({round(round_ratio * 100)}% of values). "
211
+ f"Real measurements rarely cluster at multiples of 5 or 10."
212
+ ),
213
+ evidence = (
214
+ f"Round number ratio: {round(round_ratio, 3)} "
215
+ f"(threshold: {ROUND_NUMBER_THRESHOLD}) | "
216
+ f"Round values: {round_count}/{len(float_numbers)}"
217
+ ),
218
+ suggestion = (
219
+ "Verify measurement precision. Report actual measured values "
220
+ "rather than rounded summaries."
221
+ ),
222
+ ))
223
+
224
+ return round_ratio, flags
225
+
226
+ def _check_suspicious_precision(
227
+ self, raw_numbers: List[str]
228
+ ) -> Tuple[float, List[GranularityFlag], List[str], List[str]]:
229
+ """
230
+ v2.3.4 β€” Detect suspicious decimal precision.
231
+ Uses raw strings to preserve full decimal places.
232
+ Flags > 4 decimal places as suspicious.
233
+ Flags > 8 decimal places as extreme (likely fabricated/copy-pasted constants).
234
+ """
235
+ flags = []
236
+ suspicious = []
237
+ extreme = []
238
+
239
+ for raw in raw_numbers:
240
+ if '.' not in raw:
241
+ continue
242
+ decimal_part = raw.split('.')[1]
243
+ n_decimals = len(decimal_part)
244
+
245
+ if n_decimals > EXTREME_DECIMAL_THRESHOLD:
246
+ extreme.append(raw)
247
+ elif n_decimals > SUSPICIOUS_DECIMAL_THRESHOLD:
248
+ suspicious.append(raw)
249
+
250
+ score = 0.0
251
+
252
+ if extreme:
253
+ score = max(score, 0.80)
254
+ flags.append(GranularityFlag(
255
+ flag_type = "extreme_precision",
256
+ severity = "high",
257
+ description = (
258
+ f"{len(extreme)} value(s) with extreme decimal precision "
259
+ f"(> {EXTREME_DECIMAL_THRESHOLD} decimal places). "
260
+ f"This level of precision is physically impossible for most "
261
+ f"real measurements and suggests copy-pasted constants or fabricated data."
262
+ ),
263
+ evidence = (
264
+ f"Extreme precision values: "
265
+ f"{', '.join(extreme[:5])}"
266
+ f"{'...' if len(extreme) > 5 else ''}"
267
+ ),
268
+ suggestion = (
269
+ "Real measurements should be reported to appropriate significant figures. "
270
+ "Values with > 8 decimal places are almost always mathematical constants "
271
+ "or fabricated numbers."
272
+ ),
273
+ ))
274
+
275
+ if suspicious and not extreme:
276
+ score = max(score, 0.35)
277
+ flags.append(GranularityFlag(
278
+ flag_type = "suspicious_precision",
279
+ severity = "medium",
280
+ description = (
281
+ f"{len(suspicious)} value(s) with unusually high decimal precision "
282
+ f"(> {SUSPICIOUS_DECIMAL_THRESHOLD} decimal places). "
283
+ f"Verify that reported precision matches measurement instrument capability."
284
+ ),
285
+ evidence = (
286
+ f"High-precision values: "
287
+ f"{', '.join(suspicious[:5])}"
288
+ f"{'...' if len(suspicious) > 5 else ''}"
289
+ ),
290
+ suggestion = (
291
+ "Report values to the precision of your measurement instrument. "
292
+ "Excess precision may indicate data manipulation."
293
+ ),
294
+ ))
295
+
296
+ return score, flags, suspicious, extreme
297
+
298
+ def _check_terminal_digit_bias(
299
+ self, float_numbers: List[float], raw_numbers: List[str]
300
+ ) -> Tuple[float, List[GranularityFlag]]:
301
+ """
302
+ Detect terminal digit bias.
303
+ Humans fabricating data tend to prefer certain digits (0, 5)
304
+ as the last digit of reported values.
305
+ """
306
+ flags = []
307
+
308
+ terminal_digits = []
309
+ for raw in raw_numbers:
310
+ clean = raw.replace('.', '').rstrip('0')
311
+ if clean and clean[-1].isdigit():
312
+ terminal_digits.append(int(clean[-1]))
313
+
314
+ if len(terminal_digits) < MIN_NUMBERS_FOR_BENFORD:
315
+ return 0.0, flags
316
+
317
+ zero_five_count = terminal_digits.count(0) + terminal_digits.count(5)
318
+ zero_five_ratio = zero_five_count / len(terminal_digits)
319
+ expected_ratio = 0.20 # random expectation: 2/10 digits are 0 or 5
320
+
321
+ if zero_five_ratio > 0.50:
322
+ score = min((zero_five_ratio - expected_ratio) / 0.80, 1.0)
323
+ flags.append(GranularityFlag(
324
+ flag_type = "terminal_digit_bias",
325
+ severity = "medium" if zero_five_ratio < 0.70 else "high",
326
+ description = (
327
+ f"Terminal digit bias detected: "
328
+ f"{round(zero_five_ratio * 100)}% of values end in 0 or 5 "
329
+ f"(expected ~20% by chance). "
330
+ f"Humans fabricating data disproportionately choose round endings."
331
+ ),
332
+ evidence = (
333
+ f"Zero/five terminal digit ratio: {round(zero_five_ratio, 3)} "
334
+ f"(expected: {expected_ratio}) | "
335
+ f"Digits analyzed: {len(terminal_digits)}"
336
+ ),
337
+ suggestion = (
338
+ "Verify all reported values against raw measurement records. "
339
+ "Terminal digit bias is a validated data fabrication signal."
340
+ ),
341
+ ))
342
+ return round(score, 4), flags
343
+
344
+ return 0.0, flags
345
+
346
+ def _compute_score(
347
+ self,
348
+ benford_score: float,
349
+ round_ratio: float,
350
+ prec_score: float,
351
+ digit_score: float,
352
+ ) -> float:
353
+ round_component = min(
354
+ max(round_ratio - ROUND_NUMBER_THRESHOLD, 0) / (1 - ROUND_NUMBER_THRESHOLD),
355
+ 1.0
356
+ )
357
+ score = (
358
+ benford_score * 0.30 +
359
+ round_component * 0.20 +
360
+ prec_score * 0.30 +
361
+ digit_score * 0.20
362
+ )
363
+ return min(round(score, 4), 1.0)
364
+
365
+ def _get_risk_level(self, score: float) -> str:
366
+ if score >= 0.70: return "critical"
367
+ if score >= 0.45: return "high"
368
+ if score >= 0.20: return "medium"
369
+ return "low"
src/scipeerai/modules/grim_test.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/scipeerai/modules/grim_test.py
2
+ #
3
+ # GRIM Test β€” Granularity-Related Inconsistency of Means
4
+ # Based on: Brown & Heathers (2017), Social Psychological
5
+ # and Personality Science β€” scientifically validated.
6
+ #
7
+ # Catches mathematically impossible means given sample size.
8
+ # Example: mean=2.34 with n=20 is IMPOSSIBLE.
9
+
10
+ import re
11
+ import math
12
+ from dataclasses import dataclass, field
13
+
14
+
15
+ @dataclass
16
+ class GrimFlag:
17
+ flag_type: str
18
+ severity: str
19
+ description: str
20
+ evidence: str
21
+ suggestion: str
22
+
23
+
24
+ @dataclass
25
+ class GrimResult:
26
+ impossible_means: list
27
+ possible_means: list
28
+ grim_score: float
29
+ risk_level: str
30
+ summary: str
31
+ flags: list = field(default_factory=list)
32
+ flags_count: int = 0
33
+
34
+
35
+ class GrimTest:
36
+ """
37
+ GRIM Test implementation.
38
+ Checks whether reported means are mathematically
39
+ possible given the reported sample size and scale.
40
+ """
41
+
42
+ # regex to pull mean/average + sample size pairs
43
+ MEAN_PATTERN = re.compile(
44
+ r'(?:mean|average|m)\s*[=:]\s*(-?\d+\.?\d*)',
45
+ re.IGNORECASE
46
+ )
47
+ N_PATTERN = re.compile(
48
+ r'n\s*[=:]\s*(\d+)',
49
+ re.IGNORECASE
50
+ )
51
+ FULL_PATTERN = re.compile(
52
+ r'(?:mean|average|m)\s*[=:]\s*(-?\d+\.\d+)'
53
+ r'.{0,80}'
54
+ r'n\s*[=:]\s*(\d+)'
55
+ r'|'
56
+ r'n\s*[=:]\s*(\d+)'
57
+ r'.{0,80}'
58
+ r'(?:mean|average|m)\s*[=:]\s*(-?\d+\.\d+)',
59
+ re.IGNORECASE
60
+ )
61
+
62
+ def analyze(self, text: str) -> GrimResult:
63
+ pairs = self._extract_pairs(text)
64
+ impossible = []
65
+ possible = []
66
+ flags = []
67
+
68
+ for mean_val, n_val in pairs:
69
+ ok = self._grim_check(mean_val, n_val)
70
+ if ok:
71
+ possible.append((mean_val, n_val))
72
+ else:
73
+ impossible.append((mean_val, n_val))
74
+ flags.append(GrimFlag(
75
+ flag_type = "grim_impossible_mean",
76
+ severity = "high",
77
+ description = (
78
+ f"Mean={mean_val} is mathematically "
79
+ f"impossible with N={n_val}. "
80
+ f"This value cannot arise from integer "
81
+ f"item scores β€” potential data fabrication."
82
+ ),
83
+ evidence = (
84
+ f"Reported: M={mean_val}, N={n_val} | "
85
+ f"Closest valid means: "
86
+ f"{self._nearest_valid(mean_val, n_val)}"
87
+ ),
88
+ suggestion = (
89
+ "Re-check raw data and recalculate. "
90
+ "If using Likert scales, verify item "
91
+ "scoring and sample size."
92
+ ),
93
+ ))
94
+
95
+ total = len(impossible) + len(possible)
96
+ score = (len(impossible) / total) if total > 0 else 0.0
97
+ level = self._risk(score, len(impossible))
98
+ summary = self._build_summary(
99
+ impossible, possible, score, level
100
+ )
101
+
102
+ return GrimResult(
103
+ impossible_means = impossible,
104
+ possible_means = possible,
105
+ grim_score = round(score, 4),
106
+ risk_level = level,
107
+ summary = summary,
108
+ flags = flags,
109
+ flags_count = len(flags),
110
+ )
111
+
112
+ # ── internal helpers ─────────────────────────────────────────
113
+
114
+ def _grim_check(self, mean: float, n: int) -> bool:
115
+ """
116
+ Core GRIM logic.
117
+ A mean is possible iff (mean * n) rounds to an integer.
118
+ Tolerance: 0.001 to handle floating-point noise.
119
+ """
120
+ product = mean * n
121
+ remainder = abs(product - round(product))
122
+ return remainder < 0.001
123
+
124
+ def _extract_pairs(self, text: str):
125
+ pairs = []
126
+ for m in self.FULL_PATTERN.finditer(text):
127
+ if m.group(1) and m.group(2):
128
+ mean_val = float(m.group(1))
129
+ n_val = int(m.group(2))
130
+ else:
131
+ mean_val = float(m.group(4))
132
+ n_val = int(m.group(3))
133
+ if 2 <= n_val <= 10000:
134
+ pairs.append((mean_val, n_val))
135
+ return pairs
136
+
137
+ def _nearest_valid(self, mean: float, n: int) -> str:
138
+ decimals = len(str(mean).split(".")[-1])
139
+ step = round(1 / n, decimals + 2)
140
+ lower = math.floor(mean * n) / n
141
+ upper = math.ceil(mean * n) / n
142
+ return f"{round(lower, decimals)} or {round(upper, decimals)}"
143
+
144
+ def _risk(self, score: float, count: int) -> str:
145
+ if count >= 3 or score >= 0.6:
146
+ return "critical"
147
+ if count == 2 or score >= 0.4:
148
+ return "high"
149
+ if count == 1 or score >= 0.2:
150
+ return "medium"
151
+ return "low"
152
+
153
+ def _build_summary(self, impossible, possible,
154
+ score, level) -> str:
155
+ total = len(impossible) + len(possible)
156
+ if total == 0:
157
+ return (
158
+ "GRIM Test: No mean/N pairs detected in text. "
159
+ "Add explicit M= and N= values for analysis."
160
+ )
161
+ pct = round(score * 100)
162
+ return (
163
+ f"GRIM Test analyzed {total} mean/N pair(s). "
164
+ f"{len(impossible)} impossible mean(s) detected "
165
+ f"({pct}% failure rate). "
166
+ f"Risk level: {level.upper()}."
167
+ )
src/scipeerai/modules/institutional_conflict.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/scipeerai/modules/institutional_conflict.py
2
+ #
3
+ # Module 24: Institutional Conflict Score
4
+ # Detects author institution patterns correlated with bias,
5
+ # undisclosed conflicts of interest, self-serving citation
6
+ # patterns, and funding-driven conclusion distortion.
7
+ #
8
+ # Score attribute: conflict_score (0.0 = clean, 1.0 = high conflict)
9
+ # Part of SciPeerAI Phase 6 β€” v2.3.0
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass
15
+ from collections import Counter
16
+
17
+
18
+ # ── Data classes ──────────────────────────────────────────────────────────────
19
+
20
+ @dataclass
21
+ class ConflictFlag:
22
+ flag_type: str
23
+ severity: str
24
+ description: str
25
+ evidence: str
26
+ suggestion: str
27
+
28
+
29
+ @dataclass
30
+ class ConflictResult:
31
+ institution_mentions: int
32
+ unique_institutions: int
33
+ funding_sources: list
34
+ conflict_disclosures: int
35
+ undisclosed_signals: int
36
+ self_serving_claims: int
37
+ industry_funding_signals: int
38
+ conclusion_bias_score: float
39
+ conflict_score: float
40
+ risk_level: str
41
+ summary: str
42
+ flags: list
43
+ flags_count: int
44
+
45
+
46
+ # ── Compiled patterns ─────────────────────────────────────────────────────────
47
+
48
+ _INSTITUTION_RE = re.compile(
49
+ r'\b(university|institute|college|department|laboratory|lab|'
50
+ r'school of|faculty of|division of|center for|centre for|'
51
+ r'hospital|clinic|foundation|corporation|inc\.|ltd\.|llc|'
52
+ r'pharmaceut\w+|biotech\w*|tech\w+\s+(?:corp|inc|ltd))\b',
53
+ re.IGNORECASE,
54
+ )
55
+
56
+ _FUNDING_RE = re.compile(
57
+ r'\b(funded by|supported by|grant from|sponsored by|'
58
+ r'financial support|funding from|research grant|award from|'
59
+ r'contract from|supported in part|partially funded|'
60
+ r'this work was supported|this research was funded)\b',
61
+ re.IGNORECASE,
62
+ )
63
+
64
+ _CONFLICT_DISCLOSURE_RE = re.compile(
65
+ r'\b(conflict of interest|competing interest|declare no|'
66
+ r'no conflict|no competing|disclose|disclosure|'
67
+ r'authors declare|nothing to disclose|potential conflict)\b',
68
+ re.IGNORECASE,
69
+ )
70
+
71
+ _UNDISCLOSED_SIGNAL_RE = re.compile(
72
+ r'\b(consultant\w* (?:for|to)|advisor\w* (?:to|for)|'
73
+ r'employee\w* of|employed by|stock\w*|equity|'
74
+ r'patent\w*|license\w*|royalt\w*|honorar\w*|'
75
+ r'speaker bureau|board member|scientific advisory)\b',
76
+ re.IGNORECASE,
77
+ )
78
+
79
+ _INDUSTRY_FUNDING_RE = re.compile(
80
+ r'\b(pharma\w*|biotech\w*|(?:medical|health\w*)\s+(?:corp|inc|ltd|company)|'
81
+ r'industry (?:partner|fund|grant|support)|commercial\w* fund|'
82
+ r'private (?:fund|grant|support)|corporate (?:fund|grant|sponsor))\b',
83
+ re.IGNORECASE,
84
+ )
85
+
86
+ _SELF_SERVING_RE = re.compile(
87
+ r'\b(our (?:previous|prior|earlier|recent) (?:work|study|research|findings|results)|'
88
+ r'as we (?:showed|demonstrated|reported|found|showed previously)|'
89
+ r'consistent with our|confirms our|validates our|'
90
+ r'as reported by (?:us|our group|our lab|our team)|'
91
+ r'building on our|extending our|in our (?:earlier|previous|prior))\b',
92
+ re.IGNORECASE,
93
+ )
94
+
95
+ _CONCLUSION_BIAS_RE = re.compile(
96
+ r'\b(clearly (?:demonstrate|show|prove|establish|confirm)|'
97
+ r'conclusively (?:show|prove|establish|demonstrate)|'
98
+ r'unequivocally|undeniably|without doubt|it is clear that|'
99
+ r'definitively (?:show|prove|establish)|strong evidence|'
100
+ r'compelling evidence|overwhelmingly|irrefutably|'
101
+ r'leaves no doubt|beyond question)\b',
102
+ re.IGNORECASE,
103
+ )
104
+
105
+ _INSTITUTION_NAME_RE = re.compile(
106
+ r'\b([A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+){0,3}\s+'
107
+ r'(?:University|Institute|College|Laboratory|Hospital|Foundation|Corporation))\b',
108
+ )
109
+
110
+
111
+ # ── Engine ────────────────────────────────────────────────────────────────────
112
+
113
+ class InstitutionalConflictDetector:
114
+ """
115
+ Institutional Conflict Score β€” Module 24.
116
+
117
+ Detects:
118
+ - Undisclosed conflicts of interest
119
+ - Industry funding with biased conclusions
120
+ - Excessive self-citation and self-serving claims
121
+ - Conclusion language inflated beyond evidence
122
+ - Missing or inadequate conflict disclosure statements
123
+ """
124
+
125
+ def analyze(self, text: str) -> ConflictResult:
126
+ text = (text or "").strip()
127
+ if not text:
128
+ return self._empty_result("No text provided for conflict analysis.")
129
+
130
+ institution_mentions = len(_INSTITUTION_RE.findall(text))
131
+ unique_institutions = self._count_unique_institutions(text)
132
+ funding_sources = self._extract_funding_sources(text)
133
+ conflict_disclosures = len(_CONFLICT_DISCLOSURE_RE.findall(text))
134
+ undisclosed_signals = len(_UNDISCLOSED_SIGNAL_RE.findall(text))
135
+ self_serving_claims = len(_SELF_SERVING_RE.findall(text))
136
+ industry_signals = len(_INDUSTRY_FUNDING_RE.findall(text))
137
+ conclusion_bias = self._compute_conclusion_bias(text)
138
+
139
+ conflict_score = self._compute_score(
140
+ conflict_disclosures,
141
+ undisclosed_signals,
142
+ self_serving_claims,
143
+ industry_signals,
144
+ conclusion_bias,
145
+ len(funding_sources),
146
+ )
147
+
148
+ risk_level = (
149
+ "critical" if conflict_score >= 0.75 else
150
+ "high" if conflict_score >= 0.55 else
151
+ "medium" if conflict_score >= 0.30 else
152
+ "low"
153
+ )
154
+
155
+ flags = self._build_flags(
156
+ conflict_disclosures,
157
+ undisclosed_signals,
158
+ self_serving_claims,
159
+ industry_signals,
160
+ conclusion_bias,
161
+ funding_sources,
162
+ conflict_score,
163
+ )
164
+
165
+ summary = self._build_summary(
166
+ institution_mentions,
167
+ unique_institutions,
168
+ undisclosed_signals,
169
+ self_serving_claims,
170
+ conflict_score,
171
+ risk_level,
172
+ )
173
+
174
+ return ConflictResult(
175
+ institution_mentions = institution_mentions,
176
+ unique_institutions = unique_institutions,
177
+ funding_sources = funding_sources[:10],
178
+ conflict_disclosures = conflict_disclosures,
179
+ undisclosed_signals = undisclosed_signals,
180
+ self_serving_claims = self_serving_claims,
181
+ industry_funding_signals = industry_signals,
182
+ conclusion_bias_score = round(conclusion_bias, 4),
183
+ conflict_score = round(conflict_score, 4),
184
+ risk_level = risk_level,
185
+ summary = summary,
186
+ flags = flags,
187
+ flags_count = len(flags),
188
+ )
189
+
190
+ # ── Extraction ────────────────────────────────────────────────────────────
191
+
192
+ def _count_unique_institutions(self, text: str) -> int:
193
+ matches = _INSTITUTION_NAME_RE.findall(text)
194
+ return len(set(m.strip().lower() for m in matches))
195
+
196
+ def _extract_funding_sources(self, text: str) -> list:
197
+ funding_hits = _FUNDING_RE.findall(text)
198
+ lines = text.split('\n')
199
+ sources = []
200
+ for line in lines:
201
+ if any(kw.lower() in line.lower() for kw in ['funded', 'supported', 'grant', 'sponsored']):
202
+ stripped = line.strip()
203
+ if 10 < len(stripped) < 300:
204
+ sources.append(stripped[:150])
205
+ return sources[:8] if sources else funding_hits[:8]
206
+
207
+ # ── Analysis ──────────────────────────────────────────────────────────────
208
+
209
+ def _compute_conclusion_bias(self, text: str) -> float:
210
+ words = text.split()
211
+ total_words = max(len(words), 1)
212
+ bias_count = len(_CONCLUSION_BIAS_RE.findall(text))
213
+ return round(min(1.0, (bias_count / total_words) * 80), 4)
214
+
215
+ # ── Scoring ───────────────────────────────────────────────────────────────
216
+
217
+ def _compute_score(
218
+ self,
219
+ disclosures: int,
220
+ undisclosed: int,
221
+ self_serving: int,
222
+ industry: int,
223
+ conclusion_bias: float,
224
+ funding_count: int,
225
+ ) -> float:
226
+ score = 0.0
227
+
228
+ if undisclosed > 0 and disclosures == 0:
229
+ score += min(undisclosed * 0.18, 0.45)
230
+ elif undisclosed > 0 and disclosures > 0:
231
+ score += min(undisclosed * 0.08, 0.20)
232
+
233
+ score += min(self_serving * 0.06, 0.25)
234
+ score += min(industry * 0.08, 0.25)
235
+ score += conclusion_bias * 0.25
236
+
237
+ if funding_count > 0 and disclosures == 0:
238
+ score += 0.10
239
+
240
+ return round(min(1.0, score), 4)
241
+
242
+ # ── Flags ─────────────────────────────────────────────────────────────────
243
+
244
+ def _build_flags(
245
+ self,
246
+ disclosures: int,
247
+ undisclosed: int,
248
+ self_serving: int,
249
+ industry: int,
250
+ conclusion_bias: float,
251
+ funding_sources: list,
252
+ conflict_score: float,
253
+ ) -> list:
254
+ flags = []
255
+
256
+ if undisclosed > 0 and disclosures == 0:
257
+ flags.append(ConflictFlag(
258
+ flag_type = "undisclosed_conflict_of_interest",
259
+ severity = "critical",
260
+ description = (
261
+ f"{undisclosed} potential conflict signal(s) detected "
262
+ f"with no corresponding conflict disclosure statement."
263
+ ),
264
+ evidence = (
265
+ f"Text contains references to consultancy, advisory roles, "
266
+ f"equity, patents, or speaker bureaus ({undisclosed} signals) "
267
+ f"but no 'conflict of interest' or 'competing interests' declaration."
268
+ ),
269
+ suggestion = (
270
+ "Add an explicit conflict of interest statement. "
271
+ "All financial relationships, consultancy roles, and equity "
272
+ "positions must be declared per journal ethics requirements."
273
+ ),
274
+ ))
275
+
276
+ if industry > 0 and conclusion_bias >= 0.25:
277
+ flags.append(ConflictFlag(
278
+ flag_type = "industry_funded_conclusion_bias",
279
+ severity = "high",
280
+ description = (
281
+ "Industry funding signals combined with inflated conclusion "
282
+ "language suggest potential sponsor-driven result presentation."
283
+ ),
284
+ evidence = (
285
+ f"{industry} industry funding reference(s) detected alongside "
286
+ f"conclusion bias score of {conclusion_bias:.2f}. "
287
+ f"Overly confident conclusion language in industry-funded work "
288
+ f"is a known integrity risk factor."
289
+ ),
290
+ suggestion = (
291
+ "Moderate conclusion language to match the actual strength of "
292
+ "evidence. Avoid absolute claims ('clearly proves', 'definitively "
293
+ "establishes') especially in industry-funded studies."
294
+ ),
295
+ ))
296
+
297
+ if self_serving >= 4:
298
+ flags.append(ConflictFlag(
299
+ flag_type = "excessive_self_serving_citations",
300
+ severity = "medium",
301
+ description = (
302
+ f"{self_serving} self-serving reference pattern(s) detected β€” "
303
+ f"paper disproportionately builds on and validates the authors' own prior work."
304
+ ),
305
+ evidence = (
306
+ f"Phrases like 'as we showed', 'consistent with our findings', "
307
+ f"'our previous work', 'building on our' appear {self_serving} times. "
308
+ f"This pattern can indicate citation padding or circular validation."
309
+ ),
310
+ suggestion = (
311
+ "Ensure self-citations are genuinely necessary and not inflating "
312
+ "the authors' citation count. Include independent replications "
313
+ "that confirm the findings."
314
+ ),
315
+ ))
316
+
317
+ if conclusion_bias >= 0.40:
318
+ flags.append(ConflictFlag(
319
+ flag_type = "overstated_conclusions",
320
+ severity = "high",
321
+ description = (
322
+ "Conclusion language significantly overstates the strength "
323
+ "of evidence β€” claims exceed what the data can support."
324
+ ),
325
+ evidence = (
326
+ f"Conclusion bias score: {conclusion_bias:.2f} (threshold: 0.40). "
327
+ f"Terms like 'conclusively proves', 'unequivocally demonstrates', "
328
+ f"'leaves no doubt' detected. These exceed standard scientific hedging."
329
+ ),
330
+ suggestion = (
331
+ "Use appropriately hedged language: 'our results suggest', "
332
+ "'findings are consistent with', 'evidence supports'. "
333
+ "Reserve definitive language for replicated, high-powered results."
334
+ ),
335
+ ))
336
+
337
+ if len(funding_sources) > 0 and disclosures == 0:
338
+ flags.append(ConflictFlag(
339
+ flag_type = "funding_without_disclosure",
340
+ severity = "medium",
341
+ description = (
342
+ "Funding acknowledgment found but no conflict of interest "
343
+ "disclosure statement present."
344
+ ),
345
+ evidence = (
346
+ f"{len(funding_sources)} funding source reference(s) detected. "
347
+ f"Zero conflict disclosure statements found. "
348
+ f"Most journals require explicit COI statements alongside funding."
349
+ ),
350
+ suggestion = (
351
+ "Add a dedicated 'Conflicts of Interest' section. "
352
+ "Even 'no competing interests' must be explicitly stated per "
353
+ "ICMJE and most journal editorial policies."
354
+ ),
355
+ ))
356
+
357
+ if not flags:
358
+ flags.append(ConflictFlag(
359
+ flag_type = "no_conflict_detected",
360
+ severity = "low",
361
+ description = "No significant institutional conflict indicators detected.",
362
+ evidence = (
363
+ f"Conflict score: {conflict_score:.2f}. "
364
+ f"Disclosure language present or no undisclosed signals found. "
365
+ f"Conclusion language within acceptable bounds."
366
+ ),
367
+ suggestion = (
368
+ "Maintain transparency by including explicit COI and "
369
+ "funding statements in all future submissions."
370
+ ),
371
+ ))
372
+
373
+ return flags
374
+
375
+ # ── Summary ───────────────────────────────────────────────────────────────
376
+
377
+ def _build_summary(
378
+ self,
379
+ institution_mentions: int,
380
+ unique_institutions: int,
381
+ undisclosed: int,
382
+ self_serving: int,
383
+ conflict_score: float,
384
+ risk_level: str,
385
+ ) -> str:
386
+ return (
387
+ f"Institutional conflict analysis complete. "
388
+ f"{institution_mentions} institution reference(s) found "
389
+ f"({unique_institutions} unique). "
390
+ f"{undisclosed} undisclosed conflict signal(s) detected. "
391
+ f"{self_serving} self-serving citation pattern(s) found. "
392
+ f"Overall conflict risk: {risk_level.upper()}."
393
+ )
394
+
395
+ # ── Fallback ──────────────────────────────────────────────────────────────
396
+
397
+ def _empty_result(self, msg: str) -> ConflictResult:
398
+ return ConflictResult(
399
+ institution_mentions = 0,
400
+ unique_institutions = 0,
401
+ funding_sources = [],
402
+ conflict_disclosures = 0,
403
+ undisclosed_signals = 0,
404
+ self_serving_claims = 0,
405
+ industry_funding_signals = 0,
406
+ conclusion_bias_score = 0.0,
407
+ conflict_score = 0.0,
408
+ risk_level = "low",
409
+ summary = msg,
410
+ flags = [],
411
+ flags_count = 0,
412
+ )
src/scipeerai/modules/llm_detector.py ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/scipeerai/modules/llm_detector.py
2
+ #
3
+ # LLM-Generated Paper Detector
4
+ # Detects AI-generated academic text using:
5
+ # - Burstiness analysis (human text varies, LLM uniform)
6
+ # - Vocabulary diversity (TTR β€” type-token ratio)
7
+ # - Sentence length uniformity
8
+ # - Perplexity approximation via n-gram analysis
9
+ # - LLM signature phrases detection
10
+ #
11
+ # Completely novel approach β€” no free tool does this.
12
+ # Based on research in AI text detection (2023-2024).
13
+
14
+ import re
15
+ import math
16
+ import statistics
17
+ from collections import Counter
18
+ from dataclasses import dataclass, field
19
+
20
+
21
+ @dataclass
22
+ class LLMFlag:
23
+ flag_type: str
24
+ severity: str
25
+ description: str
26
+ evidence: str
27
+ suggestion: str
28
+
29
+
30
+ @dataclass
31
+ class LLMResult:
32
+ burstiness_score: float
33
+ vocabulary_diversity: float
34
+ sentence_uniformity: float
35
+ llm_phrase_count: int
36
+ llm_score: float
37
+ risk_level: str
38
+ summary: str
39
+ flags: list = field(default_factory=list)
40
+ flags_count: int = 0
41
+
42
+
43
+ class LLMDetector:
44
+ """
45
+ LLM-Generated Paper Detector.
46
+ Human writing is bursty β€” complex sentences mixed
47
+ with simple ones, varied vocabulary, irregular rhythm.
48
+ LLM writing is uniform β€” consistent complexity,
49
+ repetitive structures, characteristic phrases.
50
+ """
51
+
52
+ # LLM signature phrases β€” common in GPT/Claude output
53
+ LLM_PHRASES = [
54
+ "it is worth noting",
55
+ "it is important to note",
56
+ "it should be noted",
57
+ "furthermore",
58
+ "moreover",
59
+ "in conclusion",
60
+ "in summary",
61
+ "this paper presents",
62
+ "this study aims to",
63
+ "the results demonstrate",
64
+ "the findings suggest",
65
+ "significantly",
66
+ "notably",
67
+ "interestingly",
68
+ "it is evident",
69
+ "plays a crucial role",
70
+ "plays an important role",
71
+ "has been widely studied",
72
+ "in recent years",
73
+ "state of the art",
74
+ "state-of-the-art",
75
+ "leveraging",
76
+ "utilize",
77
+ "utilizes",
78
+ "delve into",
79
+ "delves into",
80
+ "shed light on",
81
+ "sheds light on",
82
+ "comprehensive analysis",
83
+ "robust framework",
84
+ "novel approach",
85
+ "cutting-edge",
86
+ "landscape of",
87
+ "in the realm of",
88
+ "a testament to",
89
+ ]
90
+
91
+ # Sentence splitter
92
+ SENT_PAT = re.compile(r'[.!?]+\s+')
93
+
94
+ # Word tokenizer
95
+ WORD_PAT = re.compile(r'\b[a-z]+\b', re.IGNORECASE)
96
+
97
+ def analyze(self, text: str) -> LLMResult:
98
+ if len(text.strip()) < 100:
99
+ return LLMResult(
100
+ burstiness_score = 0.0,
101
+ vocabulary_diversity = 1.0,
102
+ sentence_uniformity = 0.0,
103
+ llm_phrase_count = 0,
104
+ llm_score = 0.0,
105
+ risk_level = "low",
106
+ summary = (
107
+ "LLM Detection: Insufficient text for analysis "
108
+ "(minimum 100 characters required)."
109
+ ),
110
+ flags = [],
111
+ flags_count= 0,
112
+ )
113
+
114
+ sentences = self._split_sentences(text)
115
+ words = self._tokenize(text)
116
+ flags = []
117
+
118
+ # ── 1. Burstiness Analysis ────────────────────────────────
119
+ burstiness = self._burstiness(sentences)
120
+
121
+ # ── 2. Vocabulary Diversity (TTR) ─────────────────────────
122
+ ttr = self._type_token_ratio(words)
123
+
124
+ # ── 3. Sentence Length Uniformity ─────────────────────────
125
+ uniformity = self._sentence_uniformity(sentences)
126
+
127
+ # ── 4. LLM Phrase Detection ───────────────────────────────
128
+ phrase_count, phrases_found = self._detect_phrases(text)
129
+
130
+ # ── Flag 1: Low burstiness ────────────────────────────────
131
+ if burstiness < 0.3 and len(sentences) >= 5:
132
+ flags.append(LLMFlag(
133
+ flag_type = "low_burstiness",
134
+ severity = "high" if burstiness < 0.15 else "medium",
135
+ description = (
136
+ f"Text burstiness score: {round(burstiness, 3)}. "
137
+ f"Human writing naturally varies between complex "
138
+ f"and simple sentences (high burstiness). "
139
+ f"This text shows unusually uniform complexity β€” "
140
+ f"a strong indicator of LLM generation."
141
+ ),
142
+ evidence = (
143
+ f"Burstiness: {round(burstiness, 3)} "
144
+ f"(human avg: 0.4-0.8) | "
145
+ f"Sentences analyzed: {len(sentences)}"
146
+ ),
147
+ suggestion = (
148
+ "If AI was used, disclose it per journal policy. "
149
+ "Human-written text naturally has rhythm variation. "
150
+ "Review for AI assistance disclosure requirements."
151
+ ),
152
+ ))
153
+
154
+ # ── Flag 2: Low vocabulary diversity ─────────────────────
155
+ if ttr < 0.4 and len(words) >= 50:
156
+ flags.append(LLMFlag(
157
+ flag_type = "low_vocabulary_diversity",
158
+ severity = "medium",
159
+ description = (
160
+ f"Type-Token Ratio: {round(ttr, 3)}. "
161
+ f"Low vocabulary diversity suggests repetitive "
162
+ f"word usage typical of LLM output. "
163
+ f"Human academic writing typically scores >0.5."
164
+ ),
165
+ evidence = (
166
+ f"TTR: {round(ttr, 3)} | "
167
+ f"Unique words: {len(set(w.lower() for w in words))} / "
168
+ f"Total words: {len(words)}"
169
+ ),
170
+ suggestion = (
171
+ "Vary vocabulary and sentence structure. "
172
+ "If AI-assisted, follow institutional disclosure policy."
173
+ ),
174
+ ))
175
+
176
+ # ── Flag 3: High sentence uniformity ─────────────────────
177
+ if uniformity > 0.7 and len(sentences) >= 5:
178
+ flags.append(LLMFlag(
179
+ flag_type = "high_sentence_uniformity",
180
+ severity = "medium",
181
+ description = (
182
+ f"Sentence length uniformity: {round(uniformity*100)}%. "
183
+ f"All sentences are suspiciously similar in length. "
184
+ f"LLMs tend to produce consistent sentence lengths; "
185
+ f"human writers vary naturally."
186
+ ),
187
+ evidence = (
188
+ f"Uniformity score: {round(uniformity*100)}% | "
189
+ f"Sentences: {len(sentences)}"
190
+ ),
191
+ suggestion = (
192
+ "Natural academic writing mixes short and long "
193
+ "sentences. High uniformity is an LLM signal."
194
+ ),
195
+ ))
196
+
197
+ # ── Flag 4: LLM signature phrases ────────────────────────
198
+ if phrase_count >= 3:
199
+ flags.append(LLMFlag(
200
+ flag_type = "llm_signature_phrases",
201
+ severity = "high" if phrase_count >= 6 else "medium",
202
+ description = (
203
+ f"{phrase_count} LLM-characteristic phrase(s) detected. "
204
+ f"Phrases like 'it is worth noting', 'furthermore', "
205
+ f"'delve into' are disproportionately common in "
206
+ f"AI-generated text compared to human writing."
207
+ ),
208
+ evidence = (
209
+ f"Phrases found: {', '.join(phrases_found[:6])} | "
210
+ f"Count: {phrase_count}"
211
+ ),
212
+ suggestion = (
213
+ "Replace generic transitional phrases with "
214
+ "discipline-specific language. Disclose AI use "
215
+ "if applicable per journal requirements."
216
+ ),
217
+ ))
218
+
219
+ score = self._aggregate_score(
220
+ burstiness, ttr, uniformity, phrase_count, sentences, words
221
+ )
222
+ level = self._risk(score, len(flags))
223
+ summary = self._build_summary(
224
+ score, level, burstiness, ttr, phrase_count, len(sentences)
225
+ )
226
+
227
+ return LLMResult(
228
+ burstiness_score = round(burstiness, 4),
229
+ vocabulary_diversity = round(ttr, 4),
230
+ sentence_uniformity = round(uniformity, 4),
231
+ llm_phrase_count = phrase_count,
232
+ llm_score = round(score, 4),
233
+ risk_level = level,
234
+ summary = summary,
235
+ flags = flags,
236
+ flags_count = len(flags),
237
+ )
238
+
239
+ # ── internal helpers ─────────────────────────────────────────
240
+
241
+ def _split_sentences(self, text: str) -> list:
242
+ sentences = self.SENT_PAT.split(text.strip())
243
+ return [s.strip() for s in sentences if len(s.strip()) > 10]
244
+
245
+ def _tokenize(self, text: str) -> list:
246
+ return self.WORD_PAT.findall(text)
247
+
248
+ def _burstiness(self, sentences: list) -> float:
249
+ """
250
+ Burstiness = coefficient of variation of sentence lengths.
251
+ High burstiness = human-like variation.
252
+ Low burstiness = LLM-like uniformity.
253
+ """
254
+ if len(sentences) < 3:
255
+ return 0.5
256
+ lengths = [len(s.split()) for s in sentences]
257
+ if statistics.mean(lengths) == 0:
258
+ return 0.5
259
+ cv = statistics.stdev(lengths) / statistics.mean(lengths)
260
+ return min(cv, 1.0)
261
+
262
+ def _type_token_ratio(self, words: list) -> float:
263
+ """TTR = unique words / total words. Higher = more diverse."""
264
+ if not words:
265
+ return 1.0
266
+ # Use sliding window TTR for longer texts
267
+ window = min(len(words), 100)
268
+ sample = words[:window]
269
+ unique = len(set(w.lower() for w in sample))
270
+ return unique / len(sample)
271
+
272
+ def _sentence_uniformity(self, sentences: list) -> float:
273
+ """
274
+ How uniform are sentence lengths?
275
+ 1.0 = all same length (LLM-like)
276
+ 0.0 = highly varied (human-like)
277
+ """
278
+ if len(sentences) < 3:
279
+ return 0.0
280
+ lengths = [len(s.split()) for s in sentences]
281
+ mean = statistics.mean(lengths)
282
+ if mean == 0:
283
+ return 0.0
284
+ stdev = statistics.stdev(lengths)
285
+ cv = stdev / mean
286
+ # Invert: high CV = low uniformity
287
+ return max(0.0, 1.0 - min(cv, 1.0))
288
+
289
+ def _detect_phrases(self, text: str) -> tuple:
290
+ text_lower = text.lower()
291
+ found = []
292
+ for phrase in self.LLM_PHRASES:
293
+ if phrase in text_lower:
294
+ found.append(phrase)
295
+ return len(found), found
296
+
297
+ def _aggregate_score(self, burstiness, ttr, uniformity,
298
+ phrase_count, sentences, words) -> float:
299
+ if len(sentences) < 3:
300
+ return 0.0
301
+ # Normalize components to 0-1 risk
302
+ burst_risk = max(0, 1 - (burstiness / 0.5))
303
+ ttr_risk = max(0, 1 - (ttr / 0.6))
304
+ uniform_risk = uniformity
305
+ phrase_risk = min(phrase_count / 8, 1.0)
306
+
307
+ score = (
308
+ burst_risk * 0.35 +
309
+ ttr_risk * 0.25 +
310
+ uniform_risk * 0.20 +
311
+ phrase_risk * 0.20
312
+ )
313
+ return min(round(score, 4), 1.0)
314
+
315
+ def _risk(self, score: float, flag_count: int) -> str:
316
+ if score >= 0.65 or flag_count >= 3:
317
+ return "critical"
318
+ if score >= 0.45 or flag_count >= 2:
319
+ return "high"
320
+ if score >= 0.25 or flag_count >= 1:
321
+ return "medium"
322
+ return "low"
323
+
324
+ def _build_summary(self, score, level, burstiness,
325
+ ttr, phrase_count, n_sentences) -> str:
326
+ pct = round(score * 100)
327
+ return (
328
+ f"LLM Detection analyzed {n_sentences} sentence(s). "
329
+ f"Burstiness: {round(burstiness*100)}% "
330
+ f"(human-like threshold: >40%). "
331
+ f"Vocabulary diversity: {round(ttr*100)}%. "
332
+ f"LLM signature phrases: {phrase_count}. "
333
+ f"AI-generation probability: {pct}%. "
334
+ f"Risk level: {level.upper()}."
335
+ )
src/scipeerai/modules/methodology_checker.py ADDED
@@ -0,0 +1,549 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Methodology Logic Checker
2
+ # -------------------------
3
+ # The most subtle form of scientific fraud is not
4
+ # fabricating data β€” it is making claims that your
5
+ # method cannot actually support.
6
+ #
7
+ # "Correlation does not imply causation" is the
8
+ # famous example. But there are dozens of variations:
9
+ # underpowered claims, wrong timeframes, missing
10
+ # controls, self-report data for clinical conclusions.
11
+ #
12
+ # This module catches them β€” using both rule-based
13
+ # pattern matching and LLM-powered reasoning.
14
+
15
+ import re
16
+ import os
17
+ import json
18
+ import urllib.request
19
+ import urllib.error
20
+ from dataclasses import dataclass
21
+ from dotenv import load_dotenv
22
+
23
+ load_dotenv()
24
+
25
+
26
+ # ── data structures ───────────────────────────────────────────
27
+
28
+ @dataclass
29
+ class MethodologyFlag:
30
+ flag_type: str
31
+ severity: str
32
+ claim: str
33
+ issue: str
34
+ evidence: str
35
+ suggestion: str
36
+
37
+
38
+ @dataclass
39
+ class MethodologyResult:
40
+ claims_found: list
41
+ methods_found: list
42
+ flags: list
43
+ llm_assessment: str
44
+ llm_available: bool
45
+ risk_score: float
46
+ risk_level: str
47
+ summary: str
48
+
49
+
50
+ # ── main class ────────────────────────────────────────────────
51
+
52
+ class MethodologyChecker:
53
+ """
54
+ Two-layer methodology analysis:
55
+
56
+ Layer 1 β€” Rule-based: fast, deterministic, catches
57
+ known patterns (causation language, tiny samples,
58
+ missing controls, short timeframes for long-term claims)
59
+
60
+ Layer 2 β€” LLM reasoning: slower, probabilistic,
61
+ catches subtle logical gaps that rules miss.
62
+ Falls back gracefully if API unavailable.
63
+ """
64
+
65
+ # words that claim causation β€” need RCT to justify
66
+ CAUSATION_WORDS = [
67
+ "causes", "caused by", "leads to", "results in",
68
+ "produces", "induces", "drives", "responsible for",
69
+ "due to", "because of", "proves that", "demonstrates that"
70
+ ]
71
+
72
+ # words that only justify correlation
73
+ CORRELATION_WORDS = [
74
+ "associated with", "correlated", "linked to",
75
+ "related to", "predicts", "suggests"
76
+ ]
77
+
78
+ # study designs that cannot prove causation
79
+ WEAK_DESIGNS = [
80
+ "survey", "questionnaire", "self-report", "cross-sectional",
81
+ "retrospective", "observational", "case study", "anecdotal"
82
+ ]
83
+
84
+ # long-term claims need long-term studies
85
+ LONGTERM_CLAIMS = [
86
+ "long-term", "chronic", "sustained", "permanent",
87
+ "lasting", "durable", "years", "lifetime"
88
+ ]
89
+
90
+ def __init__(self):
91
+ self._hf_token = os.getenv("HF_API_TOKEN", "")
92
+ # free model on HuggingFace β€” good at reasoning
93
+ self._hf_model = "HuggingFaceH4/zephyr-7b-beta"
94
+ self._hf_api_url = (
95
+ f"https://api-inference.huggingface.co/models/{self._hf_model}"
96
+ )
97
+
98
+ # ── public method ─────────────────────────────────────────
99
+
100
+ def analyze(self, text: str, abstract: str = "") -> MethodologyResult:
101
+ """
102
+ Full methodology analysis.
103
+ Pass full paper text. Optionally pass abstract separately
104
+ for cleaner claim extraction.
105
+ """
106
+ working_text = abstract if abstract else text
107
+
108
+ claims = self._extract_claims(working_text)
109
+ methods = self._extract_methods(text)
110
+
111
+ flags = []
112
+ flags.extend(self._check_causation_without_rct(text, claims))
113
+ flags.extend(self._check_weak_design_strong_claim(text, claims))
114
+ flags.extend(self._check_longterm_claim_shortterm_study(text, claims))
115
+ flags.extend(self._check_missing_control_group(text))
116
+ flags.extend(self._check_generalization(text, claims))
117
+
118
+ # try LLM reasoning β€” graceful fallback if unavailable
119
+ llm_text, llm_ok = self._llm_assess(
120
+ claims=claims,
121
+ methods=methods,
122
+ paper_snippet=text[:1500]
123
+ )
124
+
125
+ risk_score = self._calculate_risk(flags)
126
+ risk_level = self._get_risk_level(risk_score)
127
+
128
+ return MethodologyResult(
129
+ claims_found=claims,
130
+ methods_found=methods,
131
+ flags=flags,
132
+ llm_assessment=llm_text,
133
+ llm_available=llm_ok,
134
+ risk_score=round(risk_score, 3),
135
+ risk_level=risk_level,
136
+ summary=self._write_summary(flags, risk_level, llm_ok),
137
+ )
138
+
139
+ # ── claim / method extraction ─────────────────────────────
140
+
141
+ def _extract_claims(self, text: str) -> list:
142
+ """
143
+ Pull claim-like sentences β€” those that assert findings,
144
+ conclusions, or implications.
145
+ Looks for language that signals a conclusion.
146
+ """
147
+ claim_markers = [
148
+ "we found", "we show", "we demonstrate", "we conclude",
149
+ "our results", "our findings", "this study shows",
150
+ "this study demonstrates", "results indicate",
151
+ "results suggest", "data show", "analysis reveals",
152
+ "we report", "evidence suggests", "we establish"
153
+ ]
154
+ claims = []
155
+ sentences = re.split(r'[.!?]', text)
156
+
157
+ for sentence in sentences:
158
+ sentence = sentence.strip()
159
+ if not sentence or len(sentence) < 20:
160
+ continue
161
+ s_lower = sentence.lower()
162
+ if any(marker in s_lower for marker in claim_markers):
163
+ claims.append(sentence)
164
+
165
+ return claims[:8] # cap at 8 β€” enough signal
166
+
167
+ def _extract_methods(self, text: str) -> list:
168
+ """
169
+ Pull sentences from the methods section that describe
170
+ how the study was actually conducted.
171
+ """
172
+ method_markers = [
173
+ "we used", "we conducted", "we recruited", "we collected",
174
+ "we measured", "we analyzed", "participants were",
175
+ "subjects were", "samples were", "data were collected",
176
+ "randomized", "controlled", "double-blind", "survey",
177
+ "questionnaire", "interview", "experiment"
178
+ ]
179
+ methods = []
180
+ sentences = re.split(r'[.!?]', text)
181
+
182
+ for sentence in sentences:
183
+ sentence = sentence.strip()
184
+ if not sentence or len(sentence) < 20:
185
+ continue
186
+ s_lower = sentence.lower()
187
+ if any(marker in s_lower for marker in method_markers):
188
+ methods.append(sentence)
189
+
190
+ return methods[:8]
191
+
192
+ # ── rule-based checks ─────────────────────────────────────
193
+
194
+ def _check_causation_without_rct(self, text: str, claims: list) -> list:
195
+ """
196
+ v2.3.2 β€” Detects causal language without RCT.
197
+ Now triggers on causal claim alone (not requiring weak_design word)
198
+ since strong causal assertions without RCT evidence are the real risk.
199
+ """
200
+ flags = []
201
+ text_lower = text.lower()
202
+
203
+ has_causal_claim = any(
204
+ word in text_lower for word in self.CAUSATION_WORDS
205
+ )
206
+ has_weak_design = any(
207
+ design in text_lower for design in self.WEAK_DESIGNS
208
+ )
209
+ has_rct = any(
210
+ word in text_lower
211
+ for word in ["randomized", "randomised", "rct",
212
+ "control group", "placebo", "double-blind"]
213
+ )
214
+ # Negation check β€” "no control group" should NOT count as has_rct
215
+ has_negated_control = bool(re.search(
216
+ r'\b(?:no|without|did not have|lacked|absent)\s+(?:a\s+)?control\s+group\b',
217
+ text_lower
218
+ ))
219
+ if has_negated_control:
220
+ has_rct = False
221
+
222
+ # Trigger on strong causal claim alone if no RCT present
223
+ # OR causal claim + weak design combo (original logic)
224
+ should_flag = (has_causal_claim and not has_rct) or \
225
+ (has_causal_claim and has_weak_design and not has_rct)
226
+
227
+ # Also catch absolute/overconfident causal language as a standalone signal
228
+ absolute_causal = any(
229
+ phrase in text_lower
230
+ for phrase in ["prove conclusively", "proves conclusively",
231
+ "without doubt", "clearly show causation",
232
+ "demonstrates without doubt", "no doubt that"]
233
+ )
234
+
235
+ if should_flag or (absolute_causal and not has_rct):
236
+ evidence_sentence = ""
237
+ for sentence in re.split(r'[.!?]', text):
238
+ if any(w in sentence.lower() for w in self.CAUSATION_WORDS) or \
239
+ any(p in sentence.lower() for p in ["prove conclusively", "without doubt"]):
240
+ evidence_sentence = sentence.strip()
241
+ break
242
+
243
+ design_note = (
244
+ f"({', '.join([d for d in self.WEAK_DESIGNS if d in text_lower])}) "
245
+ if has_weak_design else "(no controlled design described) "
246
+ )
247
+
248
+ flags.append(MethodologyFlag(
249
+ flag_type="causation_without_rct",
250
+ severity="high",
251
+ claim="Causal language detected without RCT evidence",
252
+ issue=(
253
+ "The study uses causal or absolute language "
254
+ f"but no randomized controlled trial design "
255
+ f"{design_note}is described to support this claim."
256
+ ),
257
+ evidence=evidence_sentence or "See causal language in conclusions",
258
+ suggestion=(
259
+ "Causal claims require randomized controlled trials. "
260
+ "Replace causal language with correlation language, "
261
+ "or acknowledge the design limitation explicitly."
262
+ ),
263
+ ))
264
+
265
+ return flags
266
+
267
+ def _check_weak_design_strong_claim(
268
+ self, text: str, claims: list
269
+ ) -> list:
270
+ """
271
+ Self-report surveys and questionnaires cannot support
272
+ strong clinical or behavioral conclusions.
273
+ """
274
+ flags = []
275
+ text_lower = text.lower()
276
+
277
+ has_self_report = any(
278
+ w in text_lower
279
+ for w in ["self-report", "self report", "questionnaire",
280
+ "survey", "interview", "participants reported"]
281
+ )
282
+ has_strong_claim = any(
283
+ w in text_lower
284
+ for w in ["proves", "demonstrates", "establishes",
285
+ "confirms", "validates", "clinical evidence"]
286
+ )
287
+
288
+ if has_self_report and has_strong_claim:
289
+ flags.append(MethodologyFlag(
290
+ flag_type="weak_design_strong_claim",
291
+ severity="medium",
292
+ claim="Strong claim based on self-report data",
293
+ issue=(
294
+ "Self-report or questionnaire data has known limitations "
295
+ "(social desirability bias, recall bias) that undermine "
296
+ "strong conclusive claims."
297
+ ),
298
+ evidence="Self-report instrument combined with conclusive language",
299
+ suggestion=(
300
+ "Acknowledge self-report limitations explicitly. "
301
+ "Soften conclusions to match data quality."
302
+ ),
303
+ ))
304
+
305
+ return flags
306
+
307
+ def _check_longterm_claim_shortterm_study(
308
+ self, text: str, claims: list
309
+ ) -> list:
310
+ """
311
+ Studies lasting days or weeks cannot make
312
+ long-term or chronic effect claims.
313
+ """
314
+ flags = []
315
+ text_lower = text.lower()
316
+
317
+ has_longterm_claim = any(
318
+ w in text_lower for w in self.LONGTERM_CLAIMS
319
+ )
320
+ has_shortterm_study = any(
321
+ w in text_lower
322
+ for w in ["two weeks", "2 weeks", "one week", "1 week",
323
+ "3 days", "7 days", "short-term pilot",
324
+ "preliminary study"]
325
+ )
326
+
327
+ if has_longterm_claim and has_shortterm_study:
328
+ flags.append(MethodologyFlag(
329
+ flag_type="timeframe_mismatch",
330
+ severity="medium",
331
+ claim="Long-term claim from short-term study",
332
+ issue=(
333
+ "The study duration appears insufficient to support "
334
+ "long-term or chronic effect claims."
335
+ ),
336
+ evidence="Long-term language with short study duration",
337
+ suggestion=(
338
+ "Either extend the study duration or explicitly "
339
+ "limit claims to short-term effects only."
340
+ ),
341
+ ))
342
+
343
+ return flags
344
+
345
+ def _check_missing_control_group(self, text: str) -> list:
346
+ """
347
+ v2.3.2 β€” Fixed negation handling.
348
+ "No control group was needed" should COUNT as missing control,
349
+ not be treated as evidence a control group exists.
350
+ """
351
+ flags = []
352
+ text_lower = text.lower()
353
+
354
+ has_treatment = any(
355
+ w in text_lower
356
+ for w in ["treatment", "intervention", "drug", "therapy",
357
+ "program", "training"]
358
+ )
359
+ has_effect_claim = any(
360
+ w in text_lower
361
+ for w in ["improved", "reduced", "increased", "effective",
362
+ "significant effect", "works", "worked"]
363
+ )
364
+
365
+ # Check for control group mention
366
+ raw_has_control = any(
367
+ w in text_lower
368
+ for w in ["control group", "control condition", "placebo",
369
+ "comparison group", "waitlist"]
370
+ )
371
+ # Negation check β€” explicit denial of control group
372
+ has_negated_control = bool(re.search(
373
+ r'\b(?:no|without|did not have|lacked|absent|not needed|wasn.?t needed)\s+'
374
+ r'(?:a\s+)?control\s+group\b',
375
+ text_lower
376
+ )) or bool(re.search(
377
+ r'\bcontrol\s+group\s+(?:was\s+)?not\s+(?:needed|used|included)\b',
378
+ text_lower
379
+ ))
380
+
381
+ has_control = raw_has_control and not has_negated_control
382
+
383
+ if has_treatment and has_effect_claim and not has_control:
384
+ severity = "high" if has_negated_control else "high"
385
+ evidence = (
386
+ "Explicit denial of control group detected"
387
+ if has_negated_control else
388
+ "Treatment + effect language with no control group mention"
389
+ )
390
+
391
+ flags.append(MethodologyFlag(
392
+ flag_type="missing_control_group",
393
+ severity=severity,
394
+ claim="Treatment effect claimed without control group",
395
+ issue=(
396
+ "Effect claims for a treatment or intervention "
397
+ "require a control group to rule out confounds, "
398
+ "placebo effects, and natural recovery. "
399
+ + ("The paper explicitly states no control group was used."
400
+ if has_negated_control else "")
401
+ ),
402
+ evidence=evidence,
403
+ suggestion=(
404
+ "Add a control/comparison condition, or acknowledge "
405
+ "that without a control group, the effect cannot be "
406
+ "attributed to the intervention specifically."
407
+ ),
408
+ ))
409
+
410
+ return flags
411
+
412
+
413
+ def _check_generalization(self, text: str, claims: list) -> list:
414
+ """
415
+ Small, homogeneous samples cannot support
416
+ broad population-level generalizations.
417
+ """
418
+ flags = []
419
+ text_lower = text.lower()
420
+
421
+ has_broad_claim = any(
422
+ w in text_lower
423
+ for w in ["all patients", "general population", "universally",
424
+ "across all", "globally applicable", "all humans"]
425
+ )
426
+ has_limited_sample = any(
427
+ w in text_lower
428
+ for w in ["undergraduate students", "college students",
429
+ "single institution", "convenience sample",
430
+ "homogeneous sample"]
431
+ )
432
+
433
+ if has_broad_claim and has_limited_sample:
434
+ flags.append(MethodologyFlag(
435
+ flag_type="overgeneralization",
436
+ severity="medium",
437
+ claim="Broad generalization from limited sample",
438
+ issue=(
439
+ "The sample characteristics (e.g., undergraduate students, "
440
+ "single institution) limit generalizability beyond "
441
+ "the studied population."
442
+ ),
443
+ evidence="Broad claim language with limited sample description",
444
+ suggestion=(
445
+ "Explicitly acknowledge sampling limitations "
446
+ "and restrict claims to the studied population."
447
+ ),
448
+ ))
449
+
450
+ return flags
451
+
452
+ # ── llm reasoning ─────────────────────────────────────────
453
+
454
+ def _llm_assess(
455
+ self, claims: list, methods: list, paper_snippet: str
456
+ ) -> tuple:
457
+ """
458
+ Ask an LLM to reason about whether the methods
459
+ logically support the claims.
460
+
461
+ Returns (assessment_text, success_bool).
462
+ Falls back gracefully if token missing or API down.
463
+ """
464
+ if not self._hf_token or self._hf_token == "hf_xxxxxxxxxxxxxxxx":
465
+ return (
466
+ "LLM assessment unavailable β€” HF_API_TOKEN not configured.",
467
+ False
468
+ )
469
+
470
+ claims_text = " | ".join(claims[:3]) if claims else "Not extracted"
471
+ methods_text = " | ".join(methods[:3]) if methods else "Not extracted"
472
+
473
+ prompt = f"""[INST] You are a scientific peer reviewer.
474
+
475
+ Paper excerpt:
476
+ {paper_snippet[:800]}
477
+
478
+ Claims made: {claims_text}
479
+ Methods used: {methods_text}
480
+
481
+ In 2-3 sentences, identify the most critical logical gap between the methods and claims. Be specific and direct. [/INST]"""
482
+
483
+ try:
484
+ payload = json.dumps({
485
+ "inputs": prompt,
486
+ "parameters": {
487
+ "max_new_tokens": 150,
488
+ "temperature": 0.3,
489
+ "return_full_text": False,
490
+ }
491
+ }).encode("utf-8")
492
+
493
+ req = urllib.request.Request(
494
+ self._hf_api_url,
495
+ data=payload,
496
+ headers={
497
+ "Authorization": f"Bearer {self._hf_token}",
498
+ "Content-Type": "application/json",
499
+ },
500
+ method="POST"
501
+ )
502
+
503
+ with urllib.request.urlopen(req, timeout=30) as response:
504
+ result = json.loads(response.read().decode("utf-8"))
505
+
506
+ if isinstance(result, list) and result:
507
+ text = result[0].get("generated_text", "").strip()
508
+ return (text, True) if text else ("No assessment generated.", False)
509
+
510
+ return ("Unexpected API response format.", False)
511
+
512
+ except urllib.error.HTTPError as e:
513
+ if e.code == 503:
514
+ return ("LLM model loading β€” try again in 20 seconds.", False)
515
+ return (f"API error {e.code}: {str(e)}", False)
516
+ except Exception as e:
517
+ return (f"LLM unavailable: {str(e)}", False)
518
+
519
+ # ── scoring ───────────────────────────────────────────────
520
+
521
+ def _calculate_risk(self, flags: list) -> float:
522
+ weights = {"high": 0.35, "medium": 0.20, "low": 0.08}
523
+ score = sum(weights.get(f.severity, 0) for f in flags)
524
+ return min(score, 1.0)
525
+
526
+ def _get_risk_level(self, score: float) -> str:
527
+ if score >= 0.7: return "critical"
528
+ elif score >= 0.4: return "high"
529
+ elif score >= 0.2: return "medium"
530
+ return "low"
531
+
532
+ def _write_summary(
533
+ self, flags: list, risk_level: str, llm_ok: bool
534
+ ) -> str:
535
+ if not flags:
536
+ base = "No methodology logic issues detected."
537
+ else:
538
+ high = sum(1 for f in flags if f.severity == "high")
539
+ med = sum(1 for f in flags if f.severity == "medium")
540
+ parts = []
541
+ if high: parts.append(f"{high} high-severity issue{'s' if high > 1 else ''}")
542
+ if med: parts.append(f"{med} medium-severity concern{'s' if med > 1 else ''}")
543
+ base = (
544
+ f"Methodology checker flagged {', '.join(parts)}. "
545
+ f"Risk level: {risk_level.upper()}."
546
+ )
547
+
548
+ llm_note = " LLM reasoning included." if llm_ok else " LLM reasoning unavailable."
549
+ return base + llm_note
src/scipeerai/modules/novelty_scorer.py ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/scipeerai/modules/novelty_scorer.py
2
+ #
3
+ # Novelty Scorer
4
+ # --------------
5
+ # Science advances through genuinely new contributions.
6
+ # But with 4 million papers published per year, it is
7
+ # increasingly difficult to know if a contribution is
8
+ # truly novel or an incremental rehash of existing work.
9
+ #
10
+ # This module estimates novelty by:
11
+ # 1. Extracting key claims from the paper
12
+ # 2. Searching existing literature via Semantic Scholar
13
+ # 3. Analyzing title/abstract overlap patterns
14
+ # 4. Scoring novelty from 0.0 (not novel) to 1.0 (highly novel)
15
+
16
+ import re
17
+ import json
18
+ import time
19
+ import urllib.request
20
+ import urllib.parse
21
+ import urllib.error
22
+ from dataclasses import dataclass
23
+
24
+
25
+ # ── data structures ───────────────────────────────────────────
26
+
27
+ @dataclass
28
+ class NoveltyFlag:
29
+ flag_type: str
30
+ severity: str
31
+ description: str
32
+ evidence: str
33
+ suggestion: str
34
+
35
+
36
+ @dataclass
37
+ class RelatedWork:
38
+ title: str
39
+ year: int
40
+ authors: list
41
+ similarity_signal: str
42
+
43
+
44
+ @dataclass
45
+ class NoveltyResult:
46
+ novelty_score: float # 0.0 = not novel, 1.0 = highly novel
47
+ novelty_level: str # "low" / "moderate" / "high" / "very_high"
48
+ related_works_found: list
49
+ key_terms_extracted: list
50
+ flags: list
51
+ literature_accessible: bool # was Semantic Scholar reachable?
52
+ risk_level: str
53
+ summary: str
54
+
55
+
56
+ # ── main class ────────────────────────────────────────────────
57
+
58
+ class NoveltyScorer:
59
+ """
60
+ Estimates paper novelty using two approaches:
61
+
62
+ Approach 1 β€” Structural novelty signals (offline):
63
+ Looks for language patterns that indicate genuine
64
+ novelty vs incremental work. Fast, always available.
65
+
66
+ Approach 2 β€” Literature search (online):
67
+ Uses Semantic Scholar free API to find related papers.
68
+ Graceful fallback if API unavailable.
69
+
70
+ Final score combines both approaches.
71
+ """
72
+
73
+ # phrases that signal genuine novelty
74
+ NOVELTY_SIGNALS = [
75
+ "first study to", "first paper to", "novel approach",
76
+ "we propose a new", "we introduce", "we present a new",
77
+ "previously unexplored", "to our knowledge",
78
+ "no prior work", "first time", "new framework",
79
+ "new method", "we develop a novel", "first investigation",
80
+ "pioneer", "groundbreaking", "unprecedented",
81
+ ]
82
+
83
+ # phrases that signal incremental / derivative work
84
+ INCREMENTAL_SIGNALS = [
85
+ "extending previous work", "building on",
86
+ "similar to previous", "following the approach of",
87
+ "replicating", "consistent with prior",
88
+ "confirming previous findings", "in line with",
89
+ "as shown previously", "extending the work of",
90
+ "we replicate", "corroborating",
91
+ ]
92
+
93
+ # domain keywords for better search queries
94
+ DOMAIN_KEYWORDS = [
95
+ "machine learning", "deep learning", "neural network",
96
+ "transformer", "language model", "computer vision",
97
+ "natural language processing", "reinforcement learning",
98
+ "clinical trial", "randomized controlled",
99
+ "meta-analysis", "systematic review",
100
+ "molecular", "genomics", "proteomics",
101
+ "quantum", "photonic", "nanomaterial",
102
+ ]
103
+
104
+ def __init__(self):
105
+ self._api_base = (
106
+ "https://api.semanticscholar.org/graph/v1/paper/search"
107
+ )
108
+
109
+ # ── public method ─────────────────────────────────────────
110
+
111
+ def analyze(self, text: str, title: str = "") -> NoveltyResult:
112
+ """
113
+ Full novelty analysis.
114
+ Combines structural signals with optional literature search.
115
+ """
116
+ key_terms = self._extract_key_terms(text, title)
117
+ struct_score = self._structural_novelty_score(text)
118
+
119
+ # try live literature search
120
+ related_works = []
121
+ api_ok = False
122
+
123
+ if key_terms:
124
+ related_works, api_ok = self._search_literature(
125
+ key_terms, title
126
+ )
127
+
128
+ # combine scores
129
+ if api_ok and related_works:
130
+ # literature found β€” factor in search results
131
+ overlap_penalty = min(len(related_works) * 0.08, 0.4)
132
+ final_score = max(struct_score - overlap_penalty, 0.1)
133
+ else:
134
+ # no API or no results β€” use structural only
135
+ final_score = struct_score
136
+
137
+ final_score = round(final_score, 3)
138
+ novelty_level = self._get_novelty_level(final_score)
139
+ risk_level = self._get_risk_level(final_score)
140
+
141
+ flags = []
142
+ flags.extend(self._check_novelty_flags(text, final_score))
143
+ flags.extend(self._check_incremental_language(text))
144
+
145
+ return NoveltyResult(
146
+ novelty_score=final_score,
147
+ novelty_level=novelty_level,
148
+ related_works_found=related_works,
149
+ key_terms_extracted=key_terms,
150
+ flags=flags,
151
+ literature_accessible=api_ok,
152
+ risk_level=risk_level,
153
+ summary=self._write_summary(
154
+ final_score, novelty_level,
155
+ related_works, api_ok, flags
156
+ ),
157
+ )
158
+
159
+ # ── term extraction ───────────────────────────────────────
160
+
161
+ def _extract_key_terms(self, text: str, title: str) -> list:
162
+ """
163
+ Extract the most meaningful terms for literature search.
164
+ Priority: title terms > domain keywords > noun phrases.
165
+ """
166
+ terms = []
167
+
168
+ # title words are most informative
169
+ if title:
170
+ words = re.findall(r'\b[A-Z][a-z]{3,}\b', title)
171
+ terms.extend(words[:5])
172
+
173
+ # domain keywords present in text
174
+ t_lower = text.lower()
175
+ for kw in self.DOMAIN_KEYWORDS:
176
+ if kw in t_lower:
177
+ terms.append(kw)
178
+ if len(terms) >= 6:
179
+ break
180
+
181
+ # fallback: capitalized noun phrases from text
182
+ if len(terms) < 3:
183
+ caps = re.findall(r'\b[A-Z][a-z]+ [A-Z][a-z]+\b', text)
184
+ terms.extend(caps[:3])
185
+
186
+ # deduplicate
187
+ seen = set()
188
+ unique = []
189
+ for t in terms:
190
+ if t.lower() not in seen:
191
+ seen.add(t.lower())
192
+ unique.append(t)
193
+
194
+ return unique[:6]
195
+
196
+ # ── structural scoring ────────────────────────────────────
197
+
198
+ def _structural_novelty_score(self, text: str) -> float:
199
+ """
200
+ Score novelty from language patterns alone.
201
+ Novelty signals push score up, incremental signals push down.
202
+ Base score: 0.5 (neutral β€” unknown)
203
+ """
204
+ t_lower = text.lower()
205
+
206
+ novelty_count = sum(
207
+ 1 for s in self.NOVELTY_SIGNALS if s in t_lower
208
+ )
209
+ incremental_count = sum(
210
+ 1 for s in self.INCREMENTAL_SIGNALS if s in t_lower
211
+ )
212
+
213
+ score = 0.50
214
+ score += novelty_count * 0.08 # each novelty signal +8%
215
+ score -= incremental_count * 0.06 # each incremental signal -6%
216
+
217
+ return round(min(max(score, 0.05), 0.95), 3)
218
+
219
+ # ── literature search ─────────────────────────────────────
220
+
221
+ def _search_literature(
222
+ self, key_terms: list, title: str
223
+ ) -> tuple:
224
+ """
225
+ Search Semantic Scholar for related papers.
226
+ Returns (list of RelatedWork, success_bool).
227
+ Free API β€” no key required for basic search.
228
+ """
229
+ query_parts = key_terms[:3]
230
+ if title:
231
+ # use first 5 words of title for precision
232
+ title_words = title.split()[:5]
233
+ query_parts = title_words + query_parts[:2]
234
+
235
+ query = " ".join(query_parts)
236
+ encoded = urllib.parse.quote(query)
237
+ url = (
238
+ f"{self._api_base}"
239
+ f"?query={encoded}"
240
+ f"&fields=title,year,authors"
241
+ f"&limit=5"
242
+ )
243
+
244
+ try:
245
+ req = urllib.request.Request(
246
+ url,
247
+ headers={
248
+ "User-Agent": "SciPeerAI/0.1 (academic integrity tool)"
249
+ }
250
+ )
251
+ with urllib.request.urlopen(req, timeout=8) as resp:
252
+ data = json.loads(resp.read().decode())
253
+
254
+ papers = data.get("data", [])
255
+ related = []
256
+
257
+ for p in papers:
258
+ if not p.get("title"):
259
+ continue
260
+ authors = [
261
+ a.get("name", "")
262
+ for a in p.get("authors", [])[:3]
263
+ ]
264
+ # rough similarity β€” shared words between
265
+ # search query and result title
266
+ result_words = set(
267
+ p["title"].lower().split()
268
+ )
269
+ query_words = set(query.lower().split())
270
+ overlap = len(result_words & query_words)
271
+ signal = (
272
+ "high overlap" if overlap > 4
273
+ else "moderate overlap" if overlap > 2
274
+ else "low overlap"
275
+ )
276
+
277
+ related.append(RelatedWork(
278
+ title=p["title"],
279
+ year=p.get("year", 0),
280
+ authors=authors,
281
+ similarity_signal=signal,
282
+ ))
283
+
284
+ return related, True
285
+
286
+ except Exception:
287
+ # API unreachable β€” silent fallback
288
+ return [], False
289
+
290
+ # ── flag checks ───────────────────────────────────────────
291
+
292
+ def _check_novelty_flags(
293
+ self, text: str, score: float
294
+ ) -> list:
295
+ """Flag papers with suspiciously low novelty scores."""
296
+ flags = []
297
+
298
+ if score < 0.25:
299
+ flags.append(NoveltyFlag(
300
+ flag_type="low_novelty_score",
301
+ severity="high",
302
+ description=(
303
+ f"Novelty score of {round(score*100)}% suggests "
304
+ f"this work may be insufficiently differentiated "
305
+ f"from existing literature."
306
+ ),
307
+ evidence=f"Novelty score: {score}",
308
+ suggestion=(
309
+ "Clearly articulate what is new in this work. "
310
+ "Add a dedicated section comparing to the most "
311
+ "closely related prior work."
312
+ ),
313
+ ))
314
+ elif score < 0.45:
315
+ flags.append(NoveltyFlag(
316
+ flag_type="moderate_novelty_concern",
317
+ severity="medium",
318
+ description=(
319
+ f"Novelty score of {round(score*100)}% is moderate. "
320
+ f"The contribution may be incremental."
321
+ ),
322
+ evidence=f"Novelty score: {score}",
323
+ suggestion=(
324
+ "Strengthen the novelty argument. Be specific about "
325
+ "what prior work did NOT do that this paper does."
326
+ ),
327
+ ))
328
+
329
+ return flags
330
+
331
+ def _check_incremental_language(self, text: str) -> list:
332
+ """Flag papers that heavily signal incremental work."""
333
+ flags = []
334
+ t_lower = text.lower()
335
+
336
+ found = [s for s in self.INCREMENTAL_SIGNALS if s in t_lower]
337
+
338
+ if len(found) >= 3:
339
+ flags.append(NoveltyFlag(
340
+ flag_type="incremental_language_detected",
341
+ severity="medium",
342
+ description=(
343
+ f"Paper uses multiple phrases suggesting incremental "
344
+ f"rather than novel contribution: {found[:3]}"
345
+ ),
346
+ evidence=str(found[:3]),
347
+ suggestion=(
348
+ "This is not inherently a problem β€” incremental work "
349
+ "is valuable. But if claiming novelty, revise language "
350
+ "to clearly articulate what is genuinely new."
351
+ ),
352
+ ))
353
+
354
+ return flags
355
+
356
+ # ── scoring ───────────────────────────────────────────────
357
+
358
+ def _get_novelty_level(self, score: float) -> str:
359
+ if score >= 0.75: return "very_high"
360
+ elif score >= 0.55: return "high"
361
+ elif score >= 0.35: return "moderate"
362
+ return "low"
363
+
364
+ def _get_risk_level(self, novelty_score: float) -> str:
365
+ # low novelty = high risk of rejection / plagiarism concern
366
+ if novelty_score < 0.25: return "critical"
367
+ elif novelty_score < 0.40: return "high"
368
+ elif novelty_score < 0.60: return "medium"
369
+ return "low"
370
+
371
+ def _write_summary(
372
+ self, score: float, level: str,
373
+ related: list, api_ok: bool, flags: list
374
+ ) -> str:
375
+ pct = round(score * 100)
376
+ base = f"Novelty score: {pct}% ({level.replace('_', ' ')})."
377
+
378
+ if api_ok and related:
379
+ base += f" {len(related)} related work(s) found in literature."
380
+ elif not api_ok:
381
+ base += " Literature search unavailable β€” score based on structural analysis only."
382
+
383
+ if not flags:
384
+ base += " No novelty concerns detected."
385
+ else:
386
+ high = sum(1 for f in flags if f.severity == "high")
387
+ if high:
388
+ base += f" {high} novelty concern(s) flagged."
389
+
390
+ return base
src/scipeerai/modules/pcurve_analyzer.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/scipeerai/modules/pcurve_analyzer.py
2
+ #
3
+ # P-Curve Analyzer
4
+ # Detects publication bias by analyzing p-value distribution.
5
+ # Real effects: p-values uniformly distributed 0.00-0.05
6
+ # P-hacking: p-values cluster just below 0.05
7
+ #
8
+ # Based on Simonsohn, Nelson & Simmons (2014)
9
+ # Published in Journal of Experimental Psychology
10
+
11
+ import re
12
+ import math
13
+ from dataclasses import dataclass, field
14
+
15
+
16
+ @dataclass
17
+ class PCurveFlag:
18
+ flag_type: str
19
+ severity: str
20
+ description: str
21
+ evidence: str
22
+ suggestion: str
23
+
24
+
25
+ @dataclass
26
+ class PCurveResult:
27
+ p_values_found: list
28
+ significant_p: list
29
+ right_skew_ratio: float
30
+ clustering_score: float
31
+ pcurve_score: float
32
+ risk_level: str
33
+ summary: str
34
+ flags: list = field(default_factory=list)
35
+ flags_count: int = 0
36
+
37
+
38
+ class PCurveAnalyzer:
39
+ """
40
+ P-Curve Analyzer.
41
+ Analyzes distribution of p-values to detect
42
+ publication bias and p-hacking patterns.
43
+
44
+ Key insight:
45
+ - Real effects β†’ p-values RIGHT skewed (more near 0.01)
46
+ - P-hacking β†’ p-values cluster near 0.05
47
+ - No effect β†’ p-values uniformly distributed
48
+ """
49
+
50
+ P_PATTERN = re.compile(
51
+ r'p\s*[=<>≀]\s*(0?\.\d+)',
52
+ re.IGNORECASE
53
+ )
54
+
55
+ def analyze(self, text: str) -> PCurveResult:
56
+ all_p = self._extract_p_values(text)
57
+ sig_p = [p for p in all_p if p <= 0.05]
58
+ flags = []
59
+
60
+ if len(sig_p) < 3:
61
+ return PCurveResult(
62
+ p_values_found = all_p,
63
+ significant_p = sig_p,
64
+ right_skew_ratio = 0.0,
65
+ clustering_score = 0.0,
66
+ pcurve_score = 0.0,
67
+ risk_level = "low",
68
+ summary = (
69
+ f"P-Curve Analysis: {len(sig_p)} significant p-value(s) "
70
+ f"found. Minimum 3 required for curve analysis."
71
+ ),
72
+ flags = [],
73
+ flags_count= 0,
74
+ )
75
+
76
+ right_skew = self._right_skew_ratio(sig_p)
77
+ clustering = self._clustering_score(sig_p)
78
+ score = self._aggregate_score(right_skew, clustering, sig_p)
79
+ level = self._risk(score, clustering, right_skew)
80
+
81
+ # ── Flag 1: P-value clustering near 0.05 ─────────────────
82
+ if clustering > 0.5:
83
+ near_05 = sum(1 for p in sig_p if p >= 0.04)
84
+ flags.append(PCurveFlag(
85
+ flag_type = "p_value_clustering",
86
+ severity = "high" if clustering > 0.7 else "medium",
87
+ description = (
88
+ f"{near_05}/{len(sig_p)} significant p-values "
89
+ f"({round(near_05/len(sig_p)*100)}%) fall between "
90
+ f"0.040-0.050. This clustering pattern is the "
91
+ f"hallmark of p-hacking β€” results were likely "
92
+ f"manipulated to just reach significance."
93
+ ),
94
+ evidence = (
95
+ f"Significant p-values: {[round(p,4) for p in sig_p]} | "
96
+ f"Near-0.05 ratio: {round(clustering*100)}%"
97
+ ),
98
+ suggestion = (
99
+ "Pre-register hypotheses before data collection. "
100
+ "Report all tests conducted including non-significant. "
101
+ "Use sequential testing or Bayesian methods."
102
+ ),
103
+ ))
104
+
105
+ # ── Flag 2: Lack of right skew (no real effect) ───────────
106
+ if right_skew < 0.3 and len(sig_p) >= 4:
107
+ flags.append(PCurveFlag(
108
+ flag_type = "flat_pcurve",
109
+ severity = "medium",
110
+ description = (
111
+ f"P-curve lacks right skew β€” only {round(right_skew*100)}% "
112
+ f"of p-values fall below 0.025. A genuine effect "
113
+ f"produces a right-skewed p-curve. Flat curve suggests "
114
+ f"the findings may lack evidentiary value."
115
+ ),
116
+ evidence = (
117
+ f"Right-skew ratio: {round(right_skew*100)}% "
118
+ f"(expected >50% for real effects) | "
119
+ f"P-values: {[round(p,4) for p in sig_p]}"
120
+ ),
121
+ suggestion = (
122
+ "Conduct a power analysis. If the effect is real, "
123
+ "p-values should skew toward 0. Consider increasing "
124
+ "sample size for a more definitive test."
125
+ ),
126
+ ))
127
+
128
+ # ── Flag 3: Too many exactly 0.05 values ──────────────────
129
+ exact_05 = sum(1 for p in all_p if abs(p - 0.05) < 0.001)
130
+ if exact_05 >= 2:
131
+ flags.append(PCurveFlag(
132
+ flag_type = "exact_threshold_reporting",
133
+ severity = "medium",
134
+ description = (
135
+ f"{exact_05} p-values reported as exactly p=0.05. "
136
+ f"This is statistically rare in real data and "
137
+ f"suggests threshold-seeking behavior or rounding."
138
+ ),
139
+ evidence = (
140
+ f"{exact_05} values equal to exactly 0.050 found"
141
+ ),
142
+ suggestion = (
143
+ "Report exact p-values to 3+ decimal places. "
144
+ "Avoid rounding to threshold values."
145
+ ),
146
+ ))
147
+
148
+ summary = self._build_summary(
149
+ all_p, sig_p, score, level,
150
+ right_skew, clustering
151
+ )
152
+
153
+ return PCurveResult(
154
+ p_values_found = all_p,
155
+ significant_p = sig_p,
156
+ right_skew_ratio = round(right_skew, 4),
157
+ clustering_score = round(clustering, 4),
158
+ pcurve_score = round(score, 4),
159
+ risk_level = level,
160
+ summary = summary,
161
+ flags = flags,
162
+ flags_count = len(flags),
163
+ )
164
+
165
+ # ── internal helpers ─────────────────────────────────────────
166
+
167
+ def _extract_p_values(self, text: str) -> list:
168
+ values = []
169
+ for m in self.P_PATTERN.finditer(text):
170
+ try:
171
+ v = float(m.group(1))
172
+ if 0 < v <= 1:
173
+ values.append(round(v, 4))
174
+ except ValueError:
175
+ pass
176
+ return values
177
+
178
+ def _right_skew_ratio(self, sig_p: list) -> float:
179
+ """
180
+ Ratio of p-values below 0.025 vs 0.025-0.05.
181
+ Real effects: >50% below 0.025 (right skewed).
182
+ """
183
+ if not sig_p:
184
+ return 0.0
185
+ below_half = sum(1 for p in sig_p if p <= 0.025)
186
+ return below_half / len(sig_p)
187
+
188
+ def _clustering_score(self, sig_p: list) -> float:
189
+ """
190
+ Ratio of p-values in 0.04-0.05 range.
191
+ High clustering = p-hacking signature.
192
+ """
193
+ if not sig_p:
194
+ return 0.0
195
+ near_05 = sum(1 for p in sig_p if p >= 0.04)
196
+ return near_05 / len(sig_p)
197
+
198
+ def _aggregate_score(self, right_skew: float,
199
+ clustering: float,
200
+ sig_p: list) -> float:
201
+ """Combine signals into 0-1 risk score."""
202
+ cluster_risk = clustering
203
+ no_skew_risk = 1.0 - right_skew
204
+ score = (cluster_risk * 0.6 + no_skew_risk * 0.4)
205
+ return min(round(score, 4), 1.0)
206
+
207
+ def _risk(self, score: float,
208
+ clustering: float,
209
+ right_skew: float) -> str:
210
+ if clustering > 0.7 or score >= 0.7:
211
+ return "critical"
212
+ if clustering > 0.5 or score >= 0.5:
213
+ return "high"
214
+ if clustering > 0.3 or score >= 0.3:
215
+ return "medium"
216
+ return "low"
217
+
218
+ def _build_summary(self, all_p, sig_p, score,
219
+ level, right_skew, clustering) -> str:
220
+ pct = round(score * 100)
221
+ return (
222
+ f"P-Curve analyzed {len(all_p)} p-value(s), "
223
+ f"{len(sig_p)} significant (p≀0.05). "
224
+ f"Clustering score: {round(clustering*100)}% near p=0.05. "
225
+ f"Right-skew ratio: {round(right_skew*100)}%. "
226
+ f"Overall bias score: {pct}%. "
227
+ f"Risk level: {level.upper()}."
228
+ )
src/scipeerai/modules/peer_review_score.py ADDED
@@ -0,0 +1,522 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Peer Review Manipulation Score
2
+ # --------------------------------
3
+ # Peer review is the last defense before bad science
4
+ # enters the literature. When that defense is gamed,
5
+ # the consequences propagate for decades.
6
+ #
7
+ # Manipulation takes several forms:
8
+ # Authors suggest reviewers who are actually collaborators.
9
+ # Papers get accepted in days when months is the norm.
10
+ # Editors handle papers from their own institution.
11
+ # Authors cite the editor excessively before submission.
12
+ # Review reports are suspiciously short or praise-only.
13
+ #
14
+ # Most of these signals live in metadata β€” journal names,
15
+ # dates, acknowledgments, conflict statements.
16
+ # This module reads what is available in the paper text
17
+ # and scores the manipulation risk from those signals.
18
+
19
+ import re
20
+ from dataclasses import dataclass
21
+ from datetime import datetime
22
+
23
+
24
+ # ── constants ──────────────────────────────────────────────────────────────────
25
+
26
+ CURRENT_YEAR = datetime.now().year
27
+
28
+ # phrases that signal a potentially compromised review process
29
+ FAST_ACCEPTANCE_MARKERS = [
30
+ r'received[:\s]+(\w+\s+\d{1,2},?\s+\d{4})',
31
+ r'accepted[:\s]+(\w+\s+\d{1,2},?\s+\d{4})',
32
+ r'submitted[:\s]+(\w+\s+\d{1,2},?\s+\d{4})',
33
+ r'revised[:\s]+(\w+\s+\d{1,2},?\s+\d{4})',
34
+ ]
35
+
36
+ MONTH_ORDER = {
37
+ "january": 1, "february": 2, "march": 3, "april": 4,
38
+ "may": 5, "june": 6, "july": 7, "august": 8,
39
+ "september": 9, "october": 10, "november": 11, "december": 12,
40
+ "jan": 1, "feb": 2, "mar": 3, "apr": 4,
41
+ "jun": 6, "jul": 7, "aug": 8, "sep": 9,
42
+ "oct": 10, "nov": 11, "dec": 12,
43
+ }
44
+
45
+ # phrases that reveal undisclosed relationships
46
+ CONFLICT_RED_FLAGS = [
47
+ "no conflict", "no competing interest", "no financial interest",
48
+ "authors declare no", "declare no conflict",
49
+ ]
50
+
51
+ # phrases suggesting reviewer suggestions were made
52
+ REVIEWER_SUGGESTION_MARKERS = [
53
+ "suggested reviewer", "recommended reviewer",
54
+ "proposed reviewer", "reviewer suggestion",
55
+ ]
56
+
57
+ # special issue markers β€” these have lighter review
58
+ SPECIAL_ISSUE_MARKERS = [
59
+ "special issue", "invited paper", "invited article",
60
+ "guest editor", "symposium paper", "conference paper",
61
+ "extended version", "workshop paper",
62
+ ]
63
+
64
+ # predatory journal signals in acknowledgment or journal name context
65
+ PREDATORY_SIGNALS = [
66
+ "article processing charge", "apc waiver", "rapid publication",
67
+ "fast track", "open access fee", "publication fee waived",
68
+ "guaranteed acceptance", "no peer review", "editorial board member",
69
+ ]
70
+
71
+
72
+ # ── data structures ────────────────────────────────────────────────────────────
73
+
74
+ @dataclass
75
+ class ReviewFlag:
76
+ flag_type: str
77
+ severity: str
78
+ description: str
79
+ evidence: str
80
+ suggestion: str
81
+
82
+
83
+ @dataclass
84
+ class PeerReviewResult:
85
+ days_to_acceptance: int
86
+ has_conflict_statement: bool
87
+ conflict_credible: bool
88
+ reviewer_suggestions: bool
89
+ special_issue: bool
90
+ predatory_signals: int
91
+ self_citation_of_editor: float
92
+ manipulation_score: float
93
+ risk_level: str
94
+ summary: str
95
+ flags: list
96
+ flags_count: int
97
+
98
+
99
+ # ── main class ────────────────────────────────────────────────────────────────
100
+
101
+ class PeerReviewScorer:
102
+ """
103
+ Scores the risk that a paper's peer review process was manipulated.
104
+
105
+ Six detection layers:
106
+ 1. Unusually fast acceptance timeline
107
+ 2. Missing or boilerplate conflict of interest statement
108
+ 3. Reviewer suggestion language in the text
109
+ 4. Special issue / invited paper bypass signals
110
+ 5. Predatory journal process markers
111
+ 6. Excessive self-citation patterns suggesting editor citation farming
112
+ """
113
+
114
+ def analyze(self, text: str) -> PeerReviewResult:
115
+ flags = []
116
+
117
+ days = self._measure_acceptance_speed(text, flags)
118
+ has_conflict = self._check_conflict_statement(text)
119
+ credible_conf = self._check_conflict_credibility(text, has_conflict, flags)
120
+ reviewer_sugg = self._check_reviewer_suggestions(text, flags)
121
+ special = self._check_special_issue(text, flags)
122
+ predatory = self._check_predatory_signals(text, flags)
123
+ editor_cite = self._check_editor_self_citation(text, flags)
124
+
125
+ score = self._compute_score(
126
+ days, has_conflict, credible_conf,
127
+ reviewer_sugg, special, predatory, editor_cite
128
+ )
129
+ risk = self._get_risk_level(score)
130
+
131
+ return PeerReviewResult(
132
+ days_to_acceptance = days,
133
+ has_conflict_statement = has_conflict,
134
+ conflict_credible = credible_conf,
135
+ reviewer_suggestions = reviewer_sugg,
136
+ special_issue = special,
137
+ predatory_signals = predatory,
138
+ self_citation_of_editor = round(editor_cite, 3),
139
+ manipulation_score = round(score, 3),
140
+ risk_level = risk,
141
+ summary = self._write_summary(flags, risk, days),
142
+ flags = flags,
143
+ flags_count = len(flags),
144
+ )
145
+
146
+ # ── timeline analysis ──────────────────────────────────────────────────────
147
+
148
+ def _measure_acceptance_speed(self, text: str, flags: list) -> int:
149
+ """
150
+ Extract submission and acceptance dates and compute the gap in days.
151
+ Legitimate peer review takes 30-180 days on average.
152
+ Under 14 days is a strong manipulation signal.
153
+ Under 7 days is near-certain manipulation or editorial bypass.
154
+ """
155
+ dates = {}
156
+ text_lo = text.lower()
157
+
158
+ for marker_type in ["received", "accepted", "submitted", "revised"]:
159
+ pattern = rf'{marker_type}[:\s]+([a-z]+\s+\d{{1,2}},?\s+\d{{4}}|\d{{1,2}}\s+[a-z]+\s+\d{{4}})'
160
+ match = re.search(pattern, text_lo)
161
+ if match:
162
+ parsed = self._parse_date_string(match.group(1))
163
+ if parsed:
164
+ dates[marker_type] = parsed
165
+
166
+ submit_key = "received" if "received" in dates else "submitted"
167
+ accept_key = "accepted"
168
+
169
+ if submit_key not in dates or accept_key not in dates:
170
+ return -1
171
+
172
+ delta = (dates[accept_key] - dates[submit_key]).days
173
+
174
+ if delta < 0:
175
+ return -1
176
+
177
+ if delta <= 7:
178
+ flags.append(ReviewFlag(
179
+ flag_type = "instant_acceptance",
180
+ severity = "high",
181
+ description = (
182
+ f"Paper accepted in {delta} day(s) β€” "
183
+ f"faster than any legitimate peer review process. "
184
+ f"Standard review takes 30-180 days. "
185
+ f"This timeline indicates editorial bypass or "
186
+ f"pre-arranged acceptance."
187
+ ),
188
+ evidence = (
189
+ f"Submission to acceptance gap: {delta} days. "
190
+ f"Dates extracted from paper header."
191
+ ),
192
+ suggestion = (
193
+ "Verify the review timeline against the journal's "
194
+ "published average review duration. Report to the "
195
+ "editorial board if discrepancy is confirmed."
196
+ ),
197
+ ))
198
+ elif delta <= 14:
199
+ flags.append(ReviewFlag(
200
+ flag_type = "unusually_fast_acceptance",
201
+ severity = "medium",
202
+ description = (
203
+ f"Paper accepted in {delta} days β€” "
204
+ f"significantly below the typical 30-180 day range. "
205
+ f"This speed is consistent with compromised review."
206
+ ),
207
+ evidence = f"Submission to acceptance gap: {delta} days.",
208
+ suggestion = (
209
+ "Cross-check with the journal's stated review timeline."
210
+ ),
211
+ ))
212
+
213
+ return max(delta, 0)
214
+
215
+ def _parse_date_string(self, raw: str):
216
+ """Parse common academic date formats into a datetime object."""
217
+ raw = raw.strip().lower().replace(",", "")
218
+ parts = raw.split()
219
+
220
+ if len(parts) < 3:
221
+ return None
222
+
223
+ try:
224
+ # format: "january 15 2023" or "15 january 2023"
225
+ if parts[0].isdigit():
226
+ day = int(parts[0])
227
+ month = MONTH_ORDER.get(parts[1], 0)
228
+ year = int(parts[2])
229
+ else:
230
+ month = MONTH_ORDER.get(parts[0], 0)
231
+ day = int(parts[1])
232
+ year = int(parts[2])
233
+
234
+ if not month or not (1 <= day <= 31) or not (2000 <= year <= 2030):
235
+ return None
236
+
237
+ return datetime(year, month, day)
238
+ except (ValueError, IndexError):
239
+ return None
240
+
241
+ # ── conflict of interest ───────────────────────────────────────────────────
242
+
243
+ def _check_conflict_statement(self, text: str) -> bool:
244
+ """Check whether any conflict of interest statement exists."""
245
+ text_lo = text.lower()
246
+ return any(phrase in text_lo for phrase in CONFLICT_RED_FLAGS + [
247
+ "conflict of interest", "competing interest",
248
+ "financial disclosure", "funding source",
249
+ ])
250
+
251
+ def _check_conflict_credibility(
252
+ self, text: str, has_conflict: bool, flags: list
253
+ ) -> bool:
254
+ """
255
+ A boilerplate 'no conflicts' statement with no specific details
256
+ is less credible than a detailed disclosure.
257
+ Missing conflict statement is always a flag in funded research.
258
+ """
259
+ text_lo = text.lower()
260
+
261
+ has_funding = any(w in text_lo for w in [
262
+ "funded by", "supported by", "grant", "foundation",
263
+ "ministry", "national science", "european research",
264
+ ])
265
+
266
+ if not has_conflict and has_funding:
267
+ flags.append(ReviewFlag(
268
+ flag_type = "missing_conflict_statement",
269
+ severity = "medium",
270
+ description = (
271
+ "Funding acknowledgment detected but no conflict of "
272
+ "interest statement found. Most journals require authors "
273
+ "to declare potential conflicts when external funding exists."
274
+ ),
275
+ evidence = "Funding mention present; conflict statement absent.",
276
+ suggestion = (
277
+ "Add a conflict of interest section disclosing all "
278
+ "funding sources and potential financial relationships."
279
+ ),
280
+ ))
281
+ return False
282
+
283
+ boilerplate_only = has_conflict and not any(w in text_lo for w in [
284
+ "received funding", "consultant", "honorarium", "stock",
285
+ "employee of", "board member", "owns shares",
286
+ ])
287
+
288
+ return not boilerplate_only
289
+
290
+ # ── reviewer manipulation ──────────────────────────────────────────────────
291
+
292
+ def _check_reviewer_suggestions(self, text: str, flags: list) -> bool:
293
+ """
294
+ Some journals allow authors to suggest reviewers.
295
+ When this language appears in the paper itself, it may indicate
296
+ that the suggestion process was leveraged inappropriately.
297
+ """
298
+ text_lo = text.lower()
299
+ found = any(m in text_lo for m in REVIEWER_SUGGESTION_MARKERS)
300
+
301
+ if found:
302
+ flags.append(ReviewFlag(
303
+ flag_type = "reviewer_suggestion_language",
304
+ severity = "low",
305
+ description = (
306
+ "The paper contains language related to reviewer suggestions. "
307
+ "While suggesting reviewers is legitimate, it becomes "
308
+ "problematic when suggested reviewers are undisclosed "
309
+ "collaborators or colleagues."
310
+ ),
311
+ evidence = "Reviewer suggestion language detected in paper text.",
312
+ suggestion = (
313
+ "Verify that suggested reviewers had no prior relationship "
314
+ "with the authors within the past 5 years."
315
+ ),
316
+ ))
317
+
318
+ return found
319
+
320
+ # ── special issue bypass ───────────────────────────────────────────────────
321
+
322
+ def _check_special_issue(self, text: str, flags: list) -> bool:
323
+ """
324
+ Special issues and invited papers often undergo lighter review.
325
+ This is not inherently fraudulent but reduces scrutiny significantly.
326
+ """
327
+ text_lo = text.lower()
328
+ found = any(m in text_lo for m in SPECIAL_ISSUE_MARKERS)
329
+
330
+ if found:
331
+ flags.append(ReviewFlag(
332
+ flag_type = "special_issue_bypass",
333
+ severity = "low",
334
+ description = (
335
+ "Paper appears to be a special issue or invited submission. "
336
+ "These papers often receive expedited or reduced peer review, "
337
+ "lowering the scrutiny applied to methodology and data."
338
+ ),
339
+ evidence = "Special issue or invited paper language detected.",
340
+ suggestion = (
341
+ "Apply the same rigor to special issue papers as to "
342
+ "regular submissions when evaluating integrity."
343
+ ),
344
+ ))
345
+
346
+ return found
347
+
348
+ # ── predatory signals ──────────────────────────────────────────────────────
349
+
350
+ def _check_predatory_signals(self, text: str, flags: list) -> int:
351
+ """
352
+ Predatory journals guarantee acceptance, charge fees without
353
+ providing genuine review, and compromise the integrity of the
354
+ entire publication. Their language patterns are distinctive.
355
+ """
356
+ text_lo = text.lower()
357
+ found = [s for s in PREDATORY_SIGNALS if s in text_lo]
358
+
359
+ if len(found) >= 2:
360
+ flags.append(ReviewFlag(
361
+ flag_type = "predatory_journal_signals",
362
+ severity = "high",
363
+ description = (
364
+ f"{len(found)} predatory journal marker(s) detected. "
365
+ f"These signals suggest the paper may have been published "
366
+ f"in a venue that does not conduct genuine peer review."
367
+ ),
368
+ evidence = f"Markers found: {found[:4]}.",
369
+ suggestion = (
370
+ "Verify the journal's legitimacy using Beall's list, "
371
+ "DOAJ whitelist, or Cabell's Journalytics before "
372
+ "citing or building on this work."
373
+ ),
374
+ ))
375
+ elif len(found) == 1:
376
+ flags.append(ReviewFlag(
377
+ flag_type = "possible_predatory_signal",
378
+ severity = "low",
379
+ description = (
380
+ f"One predatory journal marker detected: '{found[0]}'. "
381
+ f"Alone this is not conclusive, but warrants verification."
382
+ ),
383
+ evidence = f"Marker: {found[0]}.",
384
+ suggestion = "Verify journal legitimacy.",
385
+ ))
386
+
387
+ return len(found)
388
+
389
+ # ── editor self-citation ───────────────────────────────────────────────────
390
+
391
+ def _check_editor_self_citation(self, text: str, flags: list) -> float:
392
+ """
393
+ Some authors identify the handling editor and then cite them
394
+ heavily β€” a known strategy to guarantee favorable review.
395
+ We measure citation density around editor-identification language.
396
+ """
397
+ text_lo = text.lower()
398
+ editor_markers = ["editor", "handling editor", "associate editor", "guest editor"]
399
+ has_editor = any(m in text_lo for m in editor_markers)
400
+
401
+ if not has_editor:
402
+ return 0.0
403
+
404
+ # count citations in a window around editor mentions
405
+ total_citations = len(re.findall(
406
+ r'[A-Z][a-z]+\s+\(\d{4}\)', text
407
+ ))
408
+
409
+ if total_citations == 0:
410
+ return 0.0
411
+
412
+ # citations within 500 chars of an editor mention
413
+ editor_adjacent = 0
414
+ for marker in editor_markers:
415
+ idx = text_lo.find(marker)
416
+ while idx != -1:
417
+ window = text[max(0, idx - 250): idx + 250]
418
+ editor_adjacent += len(re.findall(
419
+ r'[A-Z][a-z]+\s+\(\d{4}\)', window
420
+ ))
421
+ idx = text_lo.find(marker, idx + 1)
422
+
423
+ ratio = min(editor_adjacent / max(total_citations, 1), 1.0)
424
+
425
+ if ratio >= 0.30:
426
+ flags.append(ReviewFlag(
427
+ flag_type = "editor_citation_concentration",
428
+ severity = "medium",
429
+ description = (
430
+ f"{round(ratio * 100, 1)}% of citations cluster "
431
+ f"near editor-identification language. "
432
+ f"Concentrating citations around the editor is a "
433
+ f"known strategy to incentivize favorable review."
434
+ ),
435
+ evidence = (
436
+ f"Editor mentions: present. "
437
+ f"Citations near editor language: {editor_adjacent} "
438
+ f"of {total_citations} total."
439
+ ),
440
+ suggestion = (
441
+ "Ensure citations reflect genuine intellectual debt "
442
+ "rather than reviewer management strategy."
443
+ ),
444
+ ))
445
+
446
+ return ratio
447
+
448
+ # ── scoring ────────────────────────────────────────────────────────────────
449
+
450
+ def _compute_score(
451
+ self,
452
+ days: int,
453
+ has_conflict: bool,
454
+ credible_conf: bool,
455
+ reviewer_sugg: bool,
456
+ special: bool,
457
+ predatory: int,
458
+ editor_cite: float,
459
+ ) -> float:
460
+ score = 0.0
461
+
462
+ if days >= 0:
463
+ if days <= 7:
464
+ score += 0.40
465
+ elif days <= 14:
466
+ score += 0.25
467
+ elif days <= 21:
468
+ score += 0.10
469
+
470
+ if not has_conflict:
471
+ score += 0.15
472
+ elif not credible_conf:
473
+ score += 0.08
474
+
475
+ if reviewer_sugg:
476
+ score += 0.08
477
+ if special:
478
+ score += 0.05
479
+ if predatory >= 2:
480
+ score += 0.30
481
+ elif predatory == 1:
482
+ score += 0.10
483
+
484
+ score += editor_cite * 0.15
485
+
486
+ return min(score, 1.0)
487
+
488
+ def _get_risk_level(self, score: float) -> str:
489
+ if score >= 0.70: return "critical"
490
+ if score >= 0.45: return "high"
491
+ if score >= 0.20: return "medium"
492
+ return "low"
493
+
494
+ def _write_summary(
495
+ self, flags: list, risk_level: str, days: int
496
+ ) -> str:
497
+ timeline = ""
498
+ if days >= 0:
499
+ timeline = f" Acceptance timeline: {days} days."
500
+
501
+ if not flags:
502
+ return (
503
+ f"Peer Review Analysis: No manipulation signals detected."
504
+ f"{timeline} Review process appears standard. "
505
+ f"Risk level: {risk_level.upper()}."
506
+ )
507
+
508
+ high = sum(1 for f in flags if f.severity == "high")
509
+ medium = sum(1 for f in flags if f.severity == "medium")
510
+ low = sum(1 for f in flags if f.severity == "low")
511
+ parts = []
512
+ if high:
513
+ parts.append(f"{high} high-severity signal{'s' if high > 1 else ''}")
514
+ if medium:
515
+ parts.append(f"{medium} medium concern{'s' if medium > 1 else ''}")
516
+ if low:
517
+ parts.append(f"{low} low-severity indicator{'s' if low > 1 else ''}")
518
+
519
+ return (
520
+ f"Peer Review Analysis: {', '.join(parts)} detected."
521
+ f"{timeline} Risk level: {risk_level.upper()}."
522
+ )