############################################################################### # # BC POLICE DISCIPLINE, 2010-2025 # A one-click, annotated reproduction of the main findings # # WHAT THIS IS # Every substantiated police misconduct allegation published by British # Columbia's Office of the Police Complaint Commissioner (OPCC) between # September 2010 and March 2025: 1,243 allegations, 727 cases, 15 municipal # police agencies. The analysis asks how the discipline system handles # gender-based violence (GBV) compared with everything else. # # HOW TO RUN IT # Rscript reproduce.R # or, inside R / RStudio: # source("reproduce.R") # # It needs nothing but base R. No packages are installed, nothing is written # to your disk except one downloaded CSV in a temporary folder. It takes a # few seconds. Every number it prints is also on the web page, so you can # check the page against your own console. # # WHERE THE DATA COMES FROM # The CSV is downloaded from the project site. One row per allegation. The # is_gbv column marks the 107 allegations, across 70 cases, counted as # gender-based violence. # IMPORTANT: the OPCC does not label anything "gender-based violence." The 107 # come from four rules, and the CSV lets you separate every one of them: # # Rule 1 (is_gbv_reviewed = 1, 90 allegations). An AI model (Claude) read # each published case description, blind to the OPCC's own labels, and # flagged 110. A second model, told to reject any flag it could and to judge # only from the text, upheld 90. # # Rule 2 (is_gbv_deferred = 1, 2 allegations). Deference to the OPCC. Where # the OPCC itself applied a sexualized or intimate-partner-violence label, # that label governs even where the strict review would have declined. Its # labeller worked from the case file; this analysis sees only the published # summary, so a summary that omits the sexual element is a limit of the # source rather than evidence about the conduct. Two allegations enter this # way, both in file 2015-11048-03, where the text says only "unwanted # physical contact" while the OPCC's own sub-label reads "Sexualized # Harassment/Touching/Comments". # # Rules 1 and 2 together give is_gbv_narrow = 1: 92 allegations in 61 cases, # all of them conduct an officer committed. Rules 3 and 4 widen it. # # Rule 3 (is_gbv_hand_added = 1, 1 allegation). File 2015-10560, Vancouver, # where an officer had inappropriate contact with the woman whose sexual # assault he was investigating. The strict review rejected it because the # summary states no sexual conduct in terms, and the OPCC sub-label is the # generic discredit heading, so rule 2 could not reach it either. Included by # hand: it is the pattern of reaching her through police work. # # Rule 4 (is_gbv_neglect = 1, 14 allegations in 10 cases, 8 of them new). # An officer failing or mistreating a woman who reported domestic violence or # sexual assault. Hand-coded in neglect_of_gbv_report.csv, one evidence quote # per row, after a colleague pointed out that a set counting only what # officers COMMIT leaves out the woman they failed. # # READ THE SPREAD, NOT ONLY THE AVERAGE. Dismissal across the 62 cases of # conduct an officer committed runs at 32%; across the 8 cases of failing a # woman who reported it is 0 of 8. The combined 29% covers two unlike groups. # Set gbv <- d[d$is_gbv_narrow == 1, ] below to reproduce the narrower basis. # # Nothing here turns on those two: FINDING 8 re-runs the headline comparison # on the 90 alone. Section 3 of the web page documents the whole process. # ############################################################################### options(width = 100, stringsAsFactors = FALSE) say <- function(...) cat(paste0(...), "\n") rule <- function(title) { cat("\n", strrep("=", 78), "\n", title, "\n", strrep("=", 78), "\n", sep = "") } # Print a percentage with its raw counts, so no figure is unauditable. pc <- function(x) sprintf("%.0f%% (%d of %d)", 100 * mean(x), sum(x), length(x)) # --------------------------------------------------------------------------- # LOAD # --------------------------------------------------------------------------- rule("LOADING THE DATA") url <- "https://bc-police-discipline.pages.dev/data/allegations.csv" local <- file.path(tempdir(), "allegations.csv") # The site is public, no credentials needed. If the download fails, put # allegations.csv beside this script and it will be used instead. if (file.exists("allegations.csv")) { local <- "allegations.csv" say("Reading the local copy: allegations.csv") } else { say("Downloading: ", url) ok <- tryCatch({ utils::download.file(url, local, quiet = TRUE, method = "curl", extra = "--location --silent --fail") TRUE }, error = function(e) FALSE) if (!ok || !file.exists(local) || file.size(local) < 1000) { stop("Could not download the data. Download allegations.csv from the site ", "and put it in this folder, then run again.") } } d <- read.csv(local) say("Allegations: ", nrow(d)) say("Cases (files): ", length(unique(d$file_number))) say("Agencies: ", length(unique(d$agency))) say("Allegations counted as gender-based violence: ", sum(d$is_gbv)) say(" of which upheld by the strict review: ", sum(d$is_gbv_reviewed)) say(" of which kept by deferring to an OPCC label: ", sum(d$is_gbv_deferred)) gbv <- d[d$is_gbv == 1, ] # the 107 (is_gbv_narrow == 1 gives the 92) rest <- d[d$is_gbv == 0, ] # the other 1,136 ############################################################################### rule("FINDING 1 - WHERE THESE CASES GET FILED") # # WHAT IS BEING TESTED # Every allegation is charged under one heading of the Police Act. Two matter # here. "Abuse of Authority" is the heading for oppressive conduct toward a # member of the public; it is where force and unlawful arrest are charged. # "Discreditable Conduct" is the heading for conduct that brings discredit on # the department, meaning harm to the institution's standing. # # WHY IT MATTERS # The heading records what kind of wrong the system treats the conduct as. If # sexual violence were handled as a wrong done to a person, it should appear # under Abuse of Authority alongside the other conduct that injures people. ############################################################################### heading_share <- function(rows, heading) mean(rows$statutory_heading == heading) force <- d[d$conduct_category == "excessive_or_unnecessary_force" & d$is_gbv == 0, ] arrest <- d[d$conduct_category == "unlawful_arrest_detention_or_search" & d$is_gbv == 0, ] say("Filed as DISCREDITABLE CONDUCT (harm to the department's standing):") say(" gender-based violence ", pc(gbv$statutory_heading == "Discreditable Conduct")) say(" unnecessary force ", pc(force$statutory_heading == "Discreditable Conduct")) say(" unlawful arrest ", pc(arrest$statutory_heading == "Discreditable Conduct")) say("") say("Filed as ABUSE OF AUTHORITY (a wrong done to a member of the public):") say(" gender-based violence ", pc(gbv$statutory_heading == "Abuse of Authority")) say(" unnecessary force ", pc(force$statutory_heading == "Abuse of Authority")) say(" unlawful arrest ", pc(arrest$statutory_heading == "Abuse of Authority")) # A 2x2 test: is GBV filed under the persons heading at a different rate than # force? Build the table by hand so you can see exactly what is being compared. # Row 1 is the GBV allegations, row 2 the force allegations; column 1 counts the # ones filed under Abuse of Authority, column 2 all the others. tab1 <- rbind(gbv = c(sum(gbv$statutory_heading == "Abuse of Authority"), sum(gbv$statutory_heading != "Abuse of Authority")), force = c(sum(force$statutory_heading == "Abuse of Authority"), sum(force$statutory_heading != "Abuse of Authority"))) print(tab1) # look at the counts before trusting the p-value # Fisher rather than chi-square: one cell here is a single allegation, far below # the count at which the chi-square approximation is safe. say("\nFisher's exact test, GBV vs force on the persons heading: p = ", format.pval(fisher.test(tab1)$p.value, digits = 3)) # The conclusion is COMPUTED, never typed, so it cannot go stale when the basis # changes. Every "THE FINDING" line below follows the same rule. n_disc <- sum(gbv$statutory_heading == "Discreditable Conduct") n_aoa <- sum(gbv$statutory_heading == "Abuse of Authority") say("\nTHE FINDING: ", n_disc, " of the ", nrow(gbv), " gender-based violence allegations") say("are filed as harm to the department's reputation, and ", n_aoa, " as a wrong done") say("to a person. Force and unlawful arrest run the other way. In this record,") say("violence against women is the only violence filed mainly as damage to the") say("institution.") ############################################################################### rule("FINDING 2 - HOW HARD IT IS PUNISHED") # # WHAT IS BEING TESTED # Whether the outcome differs between these 92 allegations and the other # 1,151. Two outcomes: dismissal, and any measure that touches the job # (dismissal, demotion, or suspension). # # WHY IT MATTERS # If the system treated this conduct lightly, the reputational framing above # would read as minimisation. It does not. The framing coexists with severity, # which is what makes the pattern interesting rather than simply bad. ############################################################################### # pc() prints a percentage with its raw counts, so every figure stays auditable. # NOTE: this section counts ALLEGATIONS. Finding 9 repeats it counting CASES, # which is the unit the paper reports, and the rates differ. say("Officer dismissed:") say(" gender-based violence ", pc(gbv$dismissed == 1)) say(" everything else ", pc(rest$dismissed == 1)) say("Any measure touching the job (dismissal, demotion, suspension):") say(" gender-based violence ", pc(gbv$job_level_measure == 1)) say(" everything else ", pc(rest$job_level_measure == 1)) # Chi-square on a 2x2 table of dismissal by GBV status. tab2 <- table(GBV = d$is_gbv, dismissed = d$dismissed) print(tab2) say("Chi-square test: p = ", format.pval(chisq.test(tab2)$p.value, digits = 3)) # Suspension lengths, where a length was recorded. Not normally distributed, # so a rank test rather than a t-test. sg <- gbv$suspension_days[!is.na(gbv$suspension_days)] sr <- rest$suspension_days[!is.na(rest$suspension_days)] say("\nTypical suspension, days: GBV median ", median(sg), " (n=", length(sg), ") vs rest ", median(sr), " (n=", length(sr), ")") say("Wilcoxon rank-sum test: p = ", format.pval(wilcox.test(sg, sr)$p.value, digits = 3)) say("\nTHE FINDING: these allegations end in dismissal at nearly three times the") say("rate of other misconduct, and suspensions run longer. The difference is") say("far too large to be chance.") ############################################################################### rule("FINDING 3 - IT IS PUNISHED LIKE DECEIT AND CORRUPTION, NOT LIKE FORCE") # # WHAT IS BEING TESTED # Dismissal rates for every conduct category, ranked. The question is which # categories this one sits beside. # # WHY IT MATTERS # Deceit and corruption are offences against the institution's integrity. # Force and unlawful arrest are offences against members of the public. Which # neighbourhood gender-based violence falls into tells you which logic the # system is applying to it. ############################################################################### # Two aggregates over the same grouping: one counts the allegations in each # conduct category (FUN = length), the other averages the 0/1 dismissal flag # (FUN = mean), which for a 0/1 variable IS the dismissal rate. cats <- aggregate(cbind(n = dismissed) ~ conduct_category, data = d, FUN = length) rates <- aggregate(dismissed ~ conduct_category, data = d, FUN = mean) tabc <- merge(cats, rates, by = "conduct_category") # one row per category names(tabc) <- c("category", "n", "dismissal_rate") tabc$dismissal_rate <- round(100 * tabc$dismissal_rate) # proportion to percent ord <- tabc[order(-tabc$dismissal_rate), ] # steepest first print(ord, row.names = FALSE) # Where the sexualized-conduct category lands in that ranking, computed. gpos <- which(ord$category == "gendered_and_sexualized_violence") say("\nTHE FINDING: ", ord$category[1], " is highest at ", ord$dismissal_rate[1], "%.") say("Sexualized violence ranks ", gpos, " of ", nrow(ord), " at ", ord$dismissal_rate[gpos], "%, beside the categories that damage the") say("institution rather than beside the ones that injure the public.") say("Unnecessary force is 1% and unlawful arrest is 0 of 56. The conduct that") say("injures members of the public is the least punished; the conduct that") say("embarrasses the institution is the most punished.") ############################################################################### rule("FINDING 4 - CLUSTERING: WHICH CATEGORIES ARE TREATED ALIKE") # # WHAT IS BEING TESTED # Instead of reading the table above by eye, let an algorithm group the 14 # categories by their whole outcome profile: dismissal rate, job-level rate, # appeal rate, share ordered by the OPCC, share with a criminal process, # share where prosecutors laid no charge, and share using trust language. # Ward's method on standardised profiles, which is what the dendrogram on the # web page shows. # # WHY IT MATTERS # The algorithm is told nothing about what the categories mean. If it still # puts gender-based violence next to deceit and corruption, the grouping is a # property of how cases are handled, not of how we chose to describe them. ############################################################################### prof <- aggregate(cbind(dismissed, job_level_measure, appealed, criminal_process, no_charge_approved, trust_language) ~ conduct_category, data = d, FUN = mean) rownames(prof) <- prof$conduct_category prof$conduct_category <- NULL z <- scale(prof) # standardise so no single feature dominates hc <- hclust(dist(z), method = "ward.D2") say("Cluster membership when the tree is cut into 3 groups:") groups <- cutree(hc, k = 3) for (g in sort(unique(groups))) { say(" Group ", g, ": ", paste(names(groups)[groups == g], collapse = ", ")) } # Which category is nearest to gender-based violence in this space? dm <- as.matrix(dist(z)) near <- sort(dm["gendered_and_sexualized_violence", ]) near <- near[names(near) != "gendered_and_sexualized_violence"] say("\nNearest neighbours of gender-based violence (smaller = more alike):") print(round(head(near, 4), 2)) say("\nTHE FINDING: it lands with corruption, deceit and off-duty conduct, and") say("far from force and unlawful arrest. Corruption is marginally nearer than") say("deceit, so the honest phrasing is the deceit-and-corruption family rather") say("than 'it is deceit's twin'.") ############################################################################### rule("FINDING 5 - DISCIPLINE CONTINUES AFTER PROSECUTORS DECLINE") # # WHAT IS BEING TESTED # How often a criminal investigation appears in the file, and how often it # ended without a charge being approved while discipline went ahead anyway. # These two columns come from keyword searches of the case text, so they are # approximate; the web page marks them provisional for that reason. # # WHY IT MATTERS # It shows the discipline system acting as the forum of last resort for this # conduct: the criminal process starts more often than for other misconduct # and stops without a charge far more often. ############################################################################### say("A criminal process appears in the file:") say(" gender-based violence ", pc(gbv$criminal_process == 1)) say(" everything else ", pc(rest$criminal_process == 1)) say("Investigated, but prosecutors approved no charge:") say(" gender-based violence ", pc(gbv$no_charge_approved == 1)) say(" everything else ", pc(rest$no_charge_approved == 1)) # Among only those cases that reached a criminal investigation, how often did # it end without a charge? This is the sharper comparison. gi <- gbv[gbv$criminal_process == 1, ] ri <- rest[rest$criminal_process == 1, ] say("\nAmong allegations that DID reach a criminal investigation:") say(" gender-based violence, no charge ", pc(gi$no_charge_approved == 1)) say(" everything else, no charge ", pc(ri$no_charge_approved == 1)) tab5 <- rbind(c(sum(gi$no_charge_approved), sum(!gi$no_charge_approved)), c(sum(ri$no_charge_approved), sum(!ri$no_charge_approved))) say("Chi-square test: p = ", format.pval(chisq.test(tab5)$p.value, digits = 3)) say("\nAcross the whole record there are ", sum(d$no_charge_approved), " allegations investigated with no charge approved;") say(sum(gbv$no_charge_approved), " of them are gender-based violence, though it is only ", round(100 * mean(d$is_gbv)), "% of allegations.") say("\nTHE FINDING: for this conduct, discipline is frequently the only forum") say("that acts.") ############################################################################### rule("FINDING 6 - THE SEVERITY IS RECENT") # # WHAT IS BEING TESTED # Dismissal rates before and after 2018, for gender-based violence and for # deceit, measured by the year the case was concluded. # # WHY IT MATTERS # If the pattern were a long-standing feature of policing culture it should be # visible throughout. It is not. Read this cautiously: only 11 of the 90 were # decided before 2018, so the early figure rests on very few cases. ############################################################################### era <- function(rows, label) { pre <- rows[rows$year_concluded < 2018, ] post <- rows[rows$year_concluded >= 2018, ] say(" ", label, " before 2018: ", pc(pre$dismissed == 1), " 2018 or later: ", pc(post$dismissed == 1)) # "Headroom closed" = the share of the distance to 100% that the rate covered. a <- mean(pre$dismissed); b <- mean(post$dismissed) say(" share of the remaining distance to 100% that was closed: ", sprintf("%.0f%%", 100 * (b - a) / (1 - a))) } deceit <- d[d$conduct_category == "deceit_and_falsification" & d$is_gbv == 0, ] era(gbv, "gender-based violence") era(deceit, "deceit ") era(rest, "everything else ") say("\nTHE FINDING: dismissal for this conduct went from 9% to 39%. Deceit rose") say("too, from 41% to 63%. Measured as a share of the room left to climb, the") say("two moved almost identically, which is why the page treats them as one") say("family moving together rather than as one catching up to the other.") ############################################################################### rule("FINDING 7 - THE GAP SURVIVES CONTROLS") # # WHAT IS BEING TESTED # A logistic regression of dismissal on whether the allegation is GBV, # holding constant the year, how the case entered the system, and the agency. # # WHY IT MATTERS # The raw gap in Finding 2 could reflect when or where these cases arise. It # does not. NOTE: the web page fits this with standard errors clustered on the # case file, because one file can hold many allegations; base R has no clustered # errors, so the p-value here is slightly optimistic. The odds ratio is the # same, and the page reports the clustered version. ############################################################################### m <- glm(dismissed ~ is_gbv + factor(pathway) + year_concluded + factor(agency), data = d, family = binomial) co <- summary(m)$coefficients say("Odds ratio for gender-based violence: ", sprintf("%.1f", exp(co["is_gbv", "Estimate"])), " (95% CI ", sprintf("%.1f", exp(co["is_gbv", "Estimate"] - 1.96 * co["is_gbv", "Std. Error"])), " to ", sprintf("%.1f", exp(co["is_gbv", "Estimate"] + 1.96 * co["is_gbv", "Std. Error"])), ", p = ", format.pval(co["is_gbv", "Pr(>|z|)"], digits = 3), ")") say("\nTHE FINDING: holding year, route into the system and agency constant, the") say("odds of dismissal remain several times higher for this conduct. The gap in") say("Finding 2 is not an artefact of when or where these cases arose.") ############################################################################### rule("FINDING 8 - DOES THE DEFINITION OF THE SET CHANGE ANYTHING?") # # WHAT IS BEING TESTED # The headline comparison re-run on every definition of the set, from the # strictest to the widest, so a reader can see what each rule costs. # # the 90 strict review only, dropping the 2 kept by deference # the 92 is_gbv_narrow: conduct an officer committed # the 107 is_gbv: the reported basis, adding the hand-added case and the # 14 allegations of failing a woman who reported # # WHY IT MATTERS # A reader who thinks any one of these rules is the wrong call should be able # to see immediately what it costs. Where a finding moves, the rule is doing # analytical work rather than settling a definition. ############################################################################### # Subset the data three ways. Each pair is the set and its complement, because # every rate below is a comparison against everything else in the record. strict <- d[d$is_gbv_reviewed == 1, ]; srest <- d[d$is_gbv_reviewed == 0, ] narrow <- d[d$is_gbv_narrow == 1, ]; nrest <- d[d$is_gbv_narrow == 0, ] say("ALLEGATION LEVEL, dismissal rate, set vs everything else") say(" the 90 (strict review only) ", pc(strict$dismissed == 1), " vs ", pc(srest$dismissed == 1)) say(" the 92 (conduct committed) ", pc(narrow$dismissed == 1), " vs ", pc(nrest$dismissed == 1)) say(" the 107 (reported basis) ", pc(gbv$dismissed == 1), " vs ", pc(rest$dismissed == 1)) say("\nFiled as discreditable conduct") say(" the 90 ", pc(strict$statutory_heading == "Discreditable Conduct")) say(" the 92 ", pc(narrow$statutory_heading == "Discreditable Conduct")) say(" the 107 ", pc(gbv$statutory_heading == "Discreditable Conduct")) # THE SPREAD INSIDE THE WIDEST BASIS. This is the part an average hides. Collapse # to cases first, because dismissal is a decision about a person, not a charge. cs8 <- aggregate(cbind(is_gbv, is_gbv_narrow, is_gbv_neglect, dismissed) ~ file_number, d, max) committed <- cs8[cs8$is_gbv_narrow == 1 | (cs8$is_gbv == 1 & cs8$is_gbv_neglect == 0), ] failed <- cs8[cs8$is_gbv_neglect == 1 & cs8$is_gbv_narrow == 0, ] say("\nCASE LEVEL, inside the 107: the two groups are not alike") say(" conduct an officer committed ", pc(committed$dismissed == 1)) say(" failing a woman who reported ", pc(failed$dismissed == 1)) say("\nTHE FINDING: the discredit heading takes roughly three quarters of the set") say("on every definition, and dismissal moves by a few points. The widest basis") say("does average two groups that behave differently: violence an officer commits") say("is punished far above the record's baseline, and failing a woman who reported") say("is punished below it. Report the spread, not only the combined rate.") ############################################################################### rule("FINDING 9 - DOES COUNTING ALLEGATIONS MANUFACTURE THE RESULT?") # # WHAT IS BEING TESTED # Everything above counts allegations, and one officer contributes 16 of the # 92. If a few prolific files were driving the gaps, collapsing to cases would # shrink them. Here each of the 727 cases counts once: was anyone in the case # dismissed, was any allegation filed under a given heading, and so on. # # WHY IT MATTERS # This is the first objection a methodologist raises about repeated # observations from the same officer. It has to be answered, not asserted. ############################################################################### cases <- aggregate(cbind(is_gbv, dismissed, job_level_measure, criminal_process, no_charge_approved, appealed) ~ file_number, data = d, FUN = function(x) as.integer(any(x == 1))) # whether any allegation in the case carried each heading head_any <- function(h) { a <- aggregate(list(v = d$statutory_heading == h), by = list(file_number = d$file_number), FUN = function(x) as.integer(any(x))) a$v[match(cases$file_number, a$file_number)] } cases$discredit <- head_any("Discreditable Conduct") cases$abuse <- head_any("Abuse of Authority") cg <- cases[cases$is_gbv == 1, ] cr <- cases[cases$is_gbv == 0, ] say("Cases: ", nrow(cg), " gender-based violence vs ", nrow(cr), " others", " (allegations were ", nrow(gbv), " vs ", nrow(rest), ")") cmp <- function(lab, acol, ccol) { say(sprintf(" %-32s allegations %s vs %s cases %s vs %s", lab, pc(gbv[[acol]] == 1), pc(rest[[acol]] == 1), pc(cg[[ccol]] == 1), pc(cr[[ccol]] == 1))) } say("") say(sprintf(" %-32s %-28s %s", "", "BY ALLEGATION", "BY CASE")) cmp("Officer dismissed", "dismissed", "dismissed") cmp("Any job-level measure", "job_level_measure", "job_level_measure") cmp("Criminal process appears", "criminal_process", "criminal_process") cmp("No charge approved", "no_charge_approved", "no_charge_approved") cmp("Outcome appealed", "appealed", "appealed") say(sprintf(" %-32s allegations %s vs %s cases %s vs %s", "Filed under discredit heading", pc(gbv$statutory_heading == "Discreditable Conduct"), pc(rest$statutory_heading == "Discreditable Conduct"), pc(cg$discredit == 1), pc(cr$discredit == 1))) tabc <- rbind(c(sum(cg$dismissed), sum(!cg$dismissed)), c(sum(cr$dismissed), sum(!cr$dismissed))) say("\n Case-level dismissal, chi-square p = ", format.pval(chisq.test(tabc)$p.value, digits = 3)) # Compute the fold-change both ways rather than typing it, so the sentence # cannot drift from the data the way a hand-typed multiple would. fold_a <- mean(gbv$dismissed) / mean(rest$dismissed) fold_c <- mean(cg$dismissed) / mean(cr$dismissed) say(sprintf("\nTHE FINDING: every gap holds or widens when cases replace allegations.")) say(sprintf("The dismissal difference goes from about %.1f-fold by allegation to about", fold_a)) say(sprintf("%.1f-fold by case, because collapsing lowers the comparison group's rate", fold_c)) say("further than it lowers this one. Counting allegations is the conservative") say("choice here, not the flattering one.") ############################################################################### rule("WHAT THIS ANALYSIS CANNOT TELL YOU") ############################################################################### say("1. Only proven, published allegations appear here. Complaints that failed,") say(" and conduct never reported, are invisible. Every rate describes published") say(" discipline, not how often the conduct happens.") say("2. The criminal-process and no-charge columns are hand-coded across all") say(" 1,243 allegations, not keyword searches. The keyword versions ship beside") say(" them as criminal_process_probe and no_charge_approved_probe so the probe's") say(" own accuracy stays checkable; the probes miss about half the no-charge cases.") say("3. No column measures how grave each incident was, so part of any gap could") say(" reflect differences in gravity rather than in treatment.") say("4. One officer accounts for 16 allegations in this set, and most of it was") say(" concluded in 2018 or later.") say("5. Most of the set was identified by an AI model reading case text rather") say(" than by an official label. The exceptions are 2 kept by deferring to the") say(" OPCC's own label, 1 added by hand, and 14 hand-coded as failing a woman") say(" who reported. is_gbv_narrow and is_gbv_neglect separate them.") say(" Section 3 of the web page documents the checks: 93%") say(" agreement with the OPCC's own unambiguous labels, 91% agreement between") say(" two independent models, and 95% of supporting quotes verbatim.") say("") say("Full analysis, figures and sources: https://bc-police-discipline.pages.dev") cat("\n")